From 6315a392c09acb4cdcf4846de4fdab6c1c482921 Mon Sep 17 00:00:00 2001 From: baodev0 Date: Tue, 27 Oct 2026 13:58:50 +0700 Subject: [PATCH] =?UTF-8?q?feat(service):=20custom=20product,=20l=E1=BB=8B?= =?UTF-8?q?ch=20s=E1=BB=AD=20gi=C3=A1m=20s=C3=A1t=20VM=20v=C3=A0=20m=C3=A3?= =?UTF-8?q?=20ho=C3=A1=20response=20theo=20phi=C3=AAn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dịch vụ custom: bảng custom_products + services.custom_product_id; createService nhận custom{} thay product_id, giá = đơn giá tháng x số tháng chu kỳ (1/3/6/12/24/36), gia hạn tính theo cùng đơn giá, ServiceClientDetailResource trả custom_product và specs. - Giám sát VM: GET v1/system/vm-metrics/history gom lịch sử về tối đa 240 điểm; lỗi lấy dung lượng đĩa không còn làm hỏng cả thẻ metrics. - SealResponse: AES-256-GCM theo khoá phiên do proxy Next cấp qua header x-seal-key (HMAC của session id). Client opt-in bằng x-seal; chi tiết dịch vụ, mật khẩu VM và console ticket luôn được mã hoá. Body gửi lên dạng {enc} được giải mã trước validate; khoá lệch trả 428 để client lấy khoá mới. Đây là che mắt DevTools, không phải bảo mật chống chính chủ tài khoản. Co-Authored-By: Claude Sonnet 5.5 --- .../Concerns/ManagesCustomerServices.php | 16 +++- .../Api/Service/Concerns/ManagesVmMetrics.php | 31 +++++++ app/Http/Kernel.php | 2 + app/Http/Middleware/SealResponse.php | 56 ++++++++++++ .../Service/ServiceClientDetailResource.php | 28 ++++-- app/Models/CustomProduct.php | 45 ++++++++++ app/Models/Service.php | 6 ++ app/Services/Api/Order/OrderQueryService.php | 6 +- .../CustomerServiceProvisioningService.php | 40 +++++++-- app/Services/Order/ServiceOrderRecorder.php | 7 +- app/Services/PortalMonitorClient.php | 13 ++- app/Services/VmMetricsService.php | 38 ++++++++- app/Support/RenewalPricing.php | 15 +++- app/Support/ResponseCipher.php | 44 ++++++++++ ...27_000003_create_custom_products_table.php | 38 +++++++++ routes/client.php | 11 +-- .../Customer/CreateCustomServiceTest.php | 69 +++++++++++++++ tests/Unit/ResponseCipherTest.php | 85 +++++++++++++++++++ tests/Unit/VmMetricsHistoryTest.php | 32 +++++++ 19 files changed, 549 insertions(+), 33 deletions(-) create mode 100644 app/Http/Middleware/SealResponse.php create mode 100644 app/Models/CustomProduct.php create mode 100644 app/Support/ResponseCipher.php create mode 100644 database/migrations/2026_10_27_000003_create_custom_products_table.php create mode 100644 tests/Feature/Api/Admin/Customer/CreateCustomServiceTest.php create mode 100644 tests/Unit/ResponseCipherTest.php create mode 100644 tests/Unit/VmMetricsHistoryTest.php diff --git a/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerServices.php b/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerServices.php index 10489f0..9a3e224 100644 --- a/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerServices.php +++ b/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerServices.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api\Admin\Customer\Concerns; use App\Models\ActivityLog; +use App\Models\CustomProduct; use App\Models\Invoice; use App\Models\InvoiceAttachment; use App\Models\Service; @@ -46,6 +47,7 @@ trait ManagesCustomerServices { $data = $request->validate([ 'server_name' => 'nullable|string|max:255', + 'product_id' => 'nullable|integer|exists:products,id', 'status' => 'nullable|in:pending,active,suspended,expired,cancelled,terminated', 'auto_renew' => 'nullable|boolean', 'started_at' => 'nullable|date', @@ -206,7 +208,19 @@ trait ManagesCustomerServices public function createService(string $uuid, Request $request): JsonResponse { $data = $request->validate([ - 'product_id' => 'required|integer|exists:products,id', + 'product_id' => 'required_without:custom|nullable|integer|exists:products,id', + 'custom' => 'nullable|array', + 'custom.name' => 'required_with:custom|string|max:255', + 'custom.category' => ['required_with:custom', Rule::in(CustomProduct::CATEGORIES)], + 'custom.unit_price' => 'required_with:custom|numeric|min:0', + 'custom.cpu' => 'nullable|integer|min:1', + 'custom.ram_mb' => 'nullable|integer|min:1', + 'custom.disk_gb' => 'nullable|integer|min:1', + 'custom.bandwidth_gb' => 'nullable|integer|min:0', + 'custom.description' => 'nullable|string|max:5000', + 'custom.specs' => 'nullable|array|max:20', + 'custom.specs.*.label' => 'required|string|max:100', + 'custom.specs.*.value' => 'required|string|max:255', 'virtual_machine_id' => 'nullable|integer|exists:virtual_machines,id', 'server_name' => 'nullable|string|max:255', 'status' => 'nullable|in:pending,active,suspended,expired,cancelled,terminated', diff --git a/app/Http/Controllers/Api/Service/Concerns/ManagesVmMetrics.php b/app/Http/Controllers/Api/Service/Concerns/ManagesVmMetrics.php index 58c3ba8..de30a3e 100644 --- a/app/Http/Controllers/Api/Service/Concerns/ManagesVmMetrics.php +++ b/app/Http/Controllers/Api/Service/Concerns/ManagesVmMetrics.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api\Service\Concerns; use App\Models\VirtualMachines; +use Illuminate\Support\Carbon; use App\Services\VmMetricsService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -38,4 +39,34 @@ trait ManagesVmMetrics return response()->json(['success' => true, 'data' => $metrics]); } + + public function metricsHistory(Request $request, VmMetricsService $metricsService): JsonResponse + { + $data = $request->validate([ + 'product' => 'required|string', + 'from' => 'nullable|date', + 'to' => 'nullable|date|after:from', + ]); + + $to = isset($data['to']) ? Carbon::parse($data['to']) : now(); + $from = isset($data['from']) ? Carbon::parse($data['from']) : $to->copy()->subDay(); + if ($from->diffInDays($to) > 31) { + return response()->json(['success' => false, 'message' => 'Chỉ xem được tối đa 31 ngày mỗi lần.'], 422); + } + + $vm = $this->resolveVmById($data['product'], $request, requireActiveService: false); + if ($vm instanceof JsonResponse) { + return $vm; + } + + try { + $points = $metricsService->history((string) $vm->vmware_id, $from, $to); + } catch (RuntimeException $exception) { + $status = $exception->getCode() === 404 ? 404 : 502; + + return response()->json(['success' => false, 'message' => 'Không thể tải lịch sử giám sát VM.'], $status); + } + + return response()->json(['success' => true, 'data' => $points]); + } } diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index d01dcc2..cab454f 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -17,6 +17,7 @@ class Kernel extends HttpKernel \App\Http\Middleware\TrustProxies::class, \App\Http\Middleware\PreventRequestsDuringMaintenance::class, \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class, + \App\Http\Middleware\SealResponse::class, \App\Http\Middleware\TrimStrings::class, \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class, \Illuminate\Http\Middleware\HandleCors::class @@ -88,6 +89,7 @@ class Kernel extends HttpKernel 'perm' => \App\Http\Middleware\CheckPermission::class, 'restrict.local' => \App\Http\Middleware\RestrictToLocalEnvironment::class, 'portal-monitor.service' => \App\Http\Middleware\AuthenticatePortalMonitor::class, + 'seal.response' => \App\Http\Middleware\SealResponse::class, 'turnstile' => \App\Http\Middleware\VerifyTurnstile::class, ]; } diff --git a/app/Http/Middleware/SealResponse.php b/app/Http/Middleware/SealResponse.php new file mode 100644 index 0000000..d8831f9 --- /dev/null +++ b/app/Http/Middleware/SealResponse.php @@ -0,0 +1,56 @@ +headers->get(self::KEY_HEADER)); + + if ($this->openRequest($request, $key) === false) { + return new JsonResponse(['message' => 'Khoá phiên không khớp, vui lòng thử lại.'], 428); + } + + $response = $next($request); + + $wanted = $mode === 'always' || $request->headers->get(self::OPT_IN_HEADER) === '1'; + if (! $key || ! $wanted || $request->attributes->has(self::SEALED_ATTRIBUTE) || ! $response instanceof JsonResponse) { + return $response; + } + + $request->attributes->set(self::SEALED_ATTRIBUTE, true); + $response->setData(['enc' => ResponseCipher::seal($response->getContent(), $key)]); + + return $response; + } + + private function openRequest(Request $request, ?string $key): ?bool + { + $sealed = $request->isJson() ? $request->json('enc') : null; + if (! is_string($sealed)) { + return null; + } + + $data = $key ? json_decode((string) ResponseCipher::open($sealed, $key), true) : null; + if (! is_array($data)) { + return false; + } + + $request->json()->replace($data); + $request->request->replace($data); + + return true; + } +} diff --git a/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php b/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php index 9d4be25..a704a7b 100644 --- a/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php +++ b/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php @@ -47,8 +47,17 @@ class ServiceClientDetailResource extends JsonResource * * @return array */ - private function resolveSpecs($vm, $product): ?array + private function resolveSpecs($vm, $product, $custom = null): ?array { + if ($custom) { + return [ + 'cpu' => (int) ($custom->cpu ?? 0), + 'ram' => round(($custom->ram_mb ?? 0) / 1024, 2), + 'disk' => (int) ($custom->disk_gb ?? 0), + 'bandwidth' => (int) ($custom->bandwidth_gb ?? 0), + ]; + } + if ($vm && ($vm->cpu !== null || $vm->ram_mb !== null || $vm->disk_gb !== null)) { return [ 'cpu' => (int) ($vm->cpu ?? 0), @@ -85,6 +94,8 @@ class ServiceClientDetailResource extends JsonResource $order = $orderItem?->order; $vm = $this->resource->vmware; $product = $orderItem?->product ?? $this->resource->product; + $custom = $this->resource->customProduct; + $renewalSource = $custom ?? $product; $hosting = $this->resource->hostingAccount; $pleskCredentials = $hosting ? $this->resolvePleskCredentials($hosting) : null; $dnsZone = $this->resolveDnsZone(); @@ -102,18 +113,21 @@ class ServiceClientDetailResource extends JsonResource 'version' => $orderItem?->os?->version ?? $vm?->os?->version, 'group' => $orderItem?->os?->osGroup?->name ?? $vm?->os?->osGroup?->name, ], - 'specs' => $this->type === 'hosting' ? null : $this->resolveSpecs($vm, $product), + 'specs' => $this->type === 'hosting' ? null : $this->resolveSpecs($vm, $product, $custom), 'service_plan' => $this->type === 'hosting' ? $product?->servicePlan?->summary() : null, + 'custom_product' => $custom?->summary(), 'order' => $order ? [ 'total' => (float) $order->total, 'months' => (int) $orderItem->months, 'billing_label' => $orderItem->billing_label, - 'cycle_price' => (float) ($product?->pricings() - ->where('billing_cycle', $orderItem->billing_cycle) - ->where('is_active', 1) - ->value('price') ?? 0), + 'cycle_price' => $custom + ? (float) $custom->unit_price * (int) $orderItem->months + : (float) ($product?->pricings() + ->where('billing_cycle', $orderItem->billing_cycle) + ->where('is_active', 1) + ->value('price') ?? 0), 'addon_total' => \App\Support\RenewalPricing::addonTotal($this->resource, 1), - 'renewal_options' => \App\Support\RenewalPricing::options($product), + 'renewal_options' => \App\Support\RenewalPricing::options($renewalSource), ] : null, 'vm' => $vm ? [ 'ip_address' => $vm->ipAddress?->ip ?? $vm->ip, diff --git a/app/Models/CustomProduct.php b/app/Models/CustomProduct.php new file mode 100644 index 0000000..16db65a --- /dev/null +++ b/app/Models/CustomProduct.php @@ -0,0 +1,45 @@ + 'array']; + + public static function cycleCode(int $months): string + { + foreach (BillingCycleCatalog::builtInCodes() as $code) { + if (BillingCycleCatalog::monthsFor($code) === $months) { + return $code; + } + } + + return BillingCycleCatalog::codeFor($months); + } + + public function summary(): array + { + return [ + 'name' => $this->name, + 'category' => $this->category, + 'cpu' => $this->cpu, + 'ram_mb' => $this->ram_mb, + 'disk_gb' => $this->disk_gb, + 'bandwidth_gb' => $this->bandwidth_gb, + 'specs' => $this->specs ?? [], + 'description' => $this->description, + ]; + } +} diff --git a/app/Models/Service.php b/app/Models/Service.php index dc18f39..b5fb1a5 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -45,6 +45,7 @@ class Service extends Model 'uuid', 'customer_id', 'product_id', + 'custom_product_id', 'type', 'order_item_id', 'invoice_id', @@ -93,6 +94,11 @@ class Service extends Model return $this->belongsTo(Product::class, 'product_id'); } + public function customProduct() + { + return $this->belongsTo(CustomProduct::class, 'custom_product_id'); + } + /** The order item that triggered provisioning. */ public function orderItem() { diff --git a/app/Services/Api/Order/OrderQueryService.php b/app/Services/Api/Order/OrderQueryService.php index c619487..0f7e142 100644 --- a/app/Services/Api/Order/OrderQueryService.php +++ b/app/Services/Api/Order/OrderQueryService.php @@ -217,7 +217,7 @@ class OrderQueryService private function issueRenewal(int $serviceId, Customer $customer, int $months): array { $service = Service::query() - ->with(['orderItem.product']) + ->with(['orderItem.product', 'customProduct']) ->where('id', $serviceId) ->where('customer_id', $customer->id) ->whereNotIn('status', ['terminated', 'cancelled']) @@ -232,7 +232,7 @@ class OrderQueryService return ['ok' => false, 'message' => 'Không thể xác định giá gia hạn dịch vụ.', 'code' => 400]; } - $product = $service->product ?? $orderItem->product; + $product = $service->customProduct ?? $service->product ?? $orderItem->product; $option = RenewalPricing::option($product, $months); if ($option === null) { @@ -336,7 +336,7 @@ class OrderQueryService 'invoice_id' => $invoice->id, 'product_id' => $service->product_id ?: $orderItem->product_id, 'type' => 'renewal', - 'description' => 'Gia hạn dịch vụ: ' . ($orderItem->product?->name ?? $service->server_name ?? ''), + 'description' => 'Gia hạn dịch vụ: ' . ($service->customProduct?->name ?? $orderItem->product?->name ?? $service->server_name ?? ''), 'months' => $months, 'quantity' => 1, 'unit_price' => $renewalPrice, diff --git a/app/Services/Customer/CustomerServiceProvisioningService.php b/app/Services/Customer/CustomerServiceProvisioningService.php index cf8ec3b..f41f74b 100644 --- a/app/Services/Customer/CustomerServiceProvisioningService.php +++ b/app/Services/Customer/CustomerServiceProvisioningService.php @@ -4,6 +4,7 @@ namespace App\Services\Customer; use App\Helpers\OrderHelper; use App\Models\Customer; +use App\Models\CustomProduct; use App\Models\ModuleVmware; use App\Models\Order; use App\Models\Product; @@ -88,9 +89,13 @@ class CustomerServiceProvisioningService return DB::transaction(function () use ($customer, $service, $data) { $vmFields = ['virtual_machine_id', 'os_id', 'vm_cpu', 'vm_ram_mb', 'vm_disk_gb', 'vm_ip', 'vm_dns', 'vm_user', 'vm_password', 'vm_port']; - $service->fill(collect($data)->except(['price', 'billing_cycle', ...$vmFields])->all()); + $service->fill(collect($data)->except(['price', 'billing_cycle', 'product_id', ...$vmFields])->all()); $service->save(); + if (!empty($data['product_id']) && (int) $data['product_id'] !== $service->product_id) { + $this->replaceProduct($service, Product::findOrFail($data['product_id'])); + } + if (!empty($data['virtual_machine_id'])) { $this->assignVirtualMachine($service, $customer, (int) $data['virtual_machine_id']); } @@ -142,6 +147,12 @@ class CustomerServiceProvisioningService }); } + private function replaceProduct(Service $service, Product $product): void + { + $service->update(['product_id' => $product->id, 'custom_product_id' => null, 'type' => $product->product_type]); + $service->orderItem?->update(['product_id' => $product->id]); + } + private function assignVirtualMachine(Service $service, Customer $customer, int $virtualMachineId): void { $vm = VirtualMachines::with('datacenter')->findOrFail($virtualMachineId); @@ -194,9 +205,10 @@ class CustomerServiceProvisioningService { $customer = $this->resolveCustomer($uuid); - $product = Product::find($data['product_id']); + $custom = $data['custom'] ?? null; + $product = $custom ? null : Product::find($data['product_id']); abort_if( - $product->product_type === 'vps' && empty($data['virtual_machine_id']), + $product?->product_type === 'vps' && empty($data['virtual_machine_id']), 422, 'VPS service requires a virtual_machine_id', ); @@ -204,9 +216,13 @@ class CustomerServiceProvisioningService $billingCycle = $data['billing_cycle'] ?? 'monthly'; $months = OrderHelper::cycleToMonths($billingCycle); - $listPrice = $this->orderRecorder->listPrice($product, $billingCycle, $data['server_name'] ?? null); + abort_if($custom && !in_array($months, CustomProduct::CYCLE_MONTHS, true), 422, 'Chu kỳ không hợp lệ cho sản phẩm custom.'); + + $listPrice = $custom + ? (int) round((float) $custom['unit_price']) * $months + : $this->orderRecorder->listPrice($product, $billingCycle, $data['server_name'] ?? null); abort_if( - !isset($data['price']) && $listPrice === null && $product->product_type === 'domain', + !isset($data['price']) && $listPrice === null && $product?->product_type === 'domain', 422, 'Tên miền không có giá trong bảng giá tên miền, vui lòng nhập giá.', ); @@ -236,7 +252,9 @@ class CustomerServiceProvisioningService ), ); - return DB::transaction(function () use ($customer, $product, $data, $billingCycle, $months, $basePrice, $paidAmount, $paymentStatus, $paymentMethod, $clientIp) { + return DB::transaction(function () use ($customer, $product, $custom, $data, $billingCycle, $months, $basePrice, $paidAmount, $paymentStatus, $paymentMethod, $clientIp) { + $customProduct = $custom ? CustomProduct::create($custom) : null; + ['order' => $order, 'orderItem' => $orderItem, 'invoice' => $invoice] = $this->orderRecorder->create( $customer->id, $product, @@ -247,6 +265,7 @@ class CustomerServiceProvisioningService paymentMethod: $paymentMethod, clientIp: $clientIp, amountPaid: $paidAmount, + label: $customProduct?->name, ); // Ngày bắt đầu/hết hạn dùng chung cho cả module_vmware (next_due_date/next_invoice) và services. @@ -289,7 +308,8 @@ class CustomerServiceProvisioningService ? ['customer_id' => $customer->id, 'vm_id' => $vmId] : [ 'customer_id' => $customer->id, - 'product_id' => $product->id, + 'product_id' => $product?->id, + 'custom_product_id' => $customProduct?->id, 'vm_id' => null, 'server_name' => $data['server_name'] ?? null, ]; @@ -298,7 +318,9 @@ class CustomerServiceProvisioningService $service = Service::updateOrCreate($serviceLookup, [ 'customer_id' => $customer->id, - 'product_id' => $product->id, + 'product_id' => $product?->id, + 'custom_product_id' => $customProduct?->id, + 'type' => $customProduct?->category, 'vm_id' => $vmId, 'order_item_id' => $orderItem->id, 'invoice_id' => $invoice->id, @@ -317,7 +339,7 @@ class CustomerServiceProvisioningService ModuleVmware::where('id', $vmId)->update(['service_id' => $service->id]); } - return $service->load(['product', 'vmware.os.osGroup']); + return $service->load(['product', 'customProduct', 'vmware.os.osGroup']); }); } diff --git a/app/Services/Order/ServiceOrderRecorder.php b/app/Services/Order/ServiceOrderRecorder.php index 568bcd8..05ef763 100644 --- a/app/Services/Order/ServiceOrderRecorder.php +++ b/app/Services/Order/ServiceOrderRecorder.php @@ -31,6 +31,7 @@ class ServiceOrderRecorder ?string $paymentMethod = null, ?string $clientIp = null, int $amountPaid = 0, + ?string $label = null, ): array { $months = OrderHelper::cycleToMonths($billingCycle); $isDomain = $product?->product_type === 'domain'; @@ -80,7 +81,7 @@ class ServiceOrderRecorder 'invoice_id' => $invoice->id, 'product_id' => $product?->id, 'type' => 'service', - 'description' => $this->describe($product, $serverName), + 'description' => $this->describe($product, $serverName, $label), 'months' => $months, 'quantity' => 1, 'unit_price' => $price, @@ -184,9 +185,9 @@ class ServiceOrderRecorder : null; } - private function describe(?Product $product, ?string $serverName): string + private function describe(?Product $product, ?string $serverName, ?string $label = null): string { - $name = $product->name ?? 'Dịch vụ'; + $name = $label ?? $product->name ?? 'Dịch vụ'; return $serverName ? "{$name} - {$serverName}" : $name; } diff --git a/app/Services/PortalMonitorClient.php b/app/Services/PortalMonitorClient.php index 434a100..1032722 100644 --- a/app/Services/PortalMonitorClient.php +++ b/app/Services/PortalMonitorClient.php @@ -18,7 +18,16 @@ class PortalMonitorClient return $this->get($vmwareId, 'storage', 'VM storage is unavailable'); } - private function get(string $vmwareId, string $resource, string $notFoundMessage): array + public function vmHistory(string $vmwareId, \DateTimeInterface $from, \DateTimeInterface $to): array + { + return $this->get($vmwareId, 'history', 'VM metrics are unavailable', [ + 'from' => $from->format(DATE_RFC3339), + 'to' => $to->format(DATE_RFC3339), + 'limit' => 10000, + ]); + } + + private function get(string $vmwareId, string $resource, string $notFoundMessage, array $query = []): array { $url = rtrim((string) config('portal-monitor.url'), '/'); $apiKey = (string) config('portal-monitor.api_key'); @@ -31,7 +40,7 @@ class PortalMonitorClient $response = Http::acceptJson() ->withHeaders(['X-API-Key' => $apiKey]) ->timeout((int) config('portal-monitor.timeout', 5)) - ->get($url . '/api/v1/vsphere/vms/' . rawurlencode($vmwareId) . '/' . $resource); + ->get($url . '/api/v1/vsphere/vms/' . rawurlencode($vmwareId) . '/' . $resource, $query); } catch (ConnectionException) { throw new RuntimeException('Portal Monitor is unavailable'); } diff --git a/app/Services/VmMetricsService.php b/app/Services/VmMetricsService.php index f1d8653..85daa59 100644 --- a/app/Services/VmMetricsService.php +++ b/app/Services/VmMetricsService.php @@ -11,7 +11,11 @@ class VmMetricsService public function get(string $vmwareId, ?int $memoryTotalMb = null, ?int $cpuCores = null): array { $stats = $this->portalMonitor->vmMetrics($vmwareId); - $storage = $this->portalMonitor->vmStorage($vmwareId); + try { + $storage = $this->portalMonitor->vmStorage($vmwareId); + } catch (\RuntimeException) { + $storage = []; + } $provisionedDiskBytes = collect($storage['disks'] ?? [])->sum( fn ($disk) => is_array($disk) ? (int) ($disk['capacity_bytes'] ?? 0) : 0 ); @@ -38,4 +42,36 @@ class VmMetricsService 'stale' => (bool) ($stats['stale'] ?? false), ]; } + + private const HISTORY_FIELDS = [ + 'cpu' => 'cpu_usage_percent', + 'mem' => 'guest_memory_usage_percent', + 'disk_read_bps' => 'disk_read_bps', + 'disk_write_bps' => 'disk_write_bps', + 'net_rx_bps' => 'network_rx_bps', + 'net_tx_bps' => 'network_tx_bps', + ]; + + public function history(string $vmwareId, \DateTimeInterface $from, \DateTimeInterface $to, int $maxPoints = 240): array + { + $rows = $this->portalMonitor->vmHistory($vmwareId, $from, $to); + $span = max(1, $to->getTimestamp() - $from->getTimestamp()); + $bucket = max(1, (int) ceil($span / $maxPoints)); + + return collect($rows) + ->filter(fn ($row) => is_array($row) && isset($row['timestamp'])) + ->groupBy(fn ($row) => intdiv(strtotime($row['timestamp']), $bucket)) + ->map(function ($group, $key) use ($bucket) { + $point = ['t' => gmdate('c', $key * $bucket + intdiv($bucket, 2))]; + foreach (self::HISTORY_FIELDS as $name => $source) { + $values = $group->pluck($source)->filter(fn ($v) => $v !== null); + $point[$name] = $values->isEmpty() ? null : round($values->avg(), 2); + } + + return $point; + }) + ->sortKeys() + ->values() + ->all(); + } } diff --git a/app/Support/RenewalPricing.php b/app/Support/RenewalPricing.php index 228048c..2a9aec7 100644 --- a/app/Support/RenewalPricing.php +++ b/app/Support/RenewalPricing.php @@ -5,14 +5,25 @@ declare(strict_types=1); namespace App\Support; use App\Models\Addon; +use App\Models\CustomProduct; use App\Models\Product; use App\Models\Service; class RenewalPricing { /** @return list */ - public static function options(?Product $product): array + public static function options(Product|CustomProduct|null $product): array { + if ($product instanceof CustomProduct) { + return $product->unit_price > 0 + ? array_map(static fn (int $months): array => [ + 'billing_cycle' => CustomProduct::cycleCode($months), + 'months' => $months, + 'price' => (int) $product->unit_price * $months, + ], CustomProduct::CYCLE_MONTHS) + : []; + } + if (! $product) { return []; } @@ -33,7 +44,7 @@ class RenewalPricing return array_values($options); } - public static function option(?Product $product, int $months): ?array + public static function option(Product|CustomProduct|null $product, int $months): ?array { foreach (self::options($product) as $option) { if ($option['months'] === $months) { diff --git a/app/Support/ResponseCipher.php b/app/Support/ResponseCipher.php new file mode 100644 index 0000000..477eec4 --- /dev/null +++ b/app/Support/ResponseCipher.php @@ -0,0 +1,44 @@ +id(); + $table->string('name'); + $table->string('category', 50)->index(); + $table->unsignedInteger('cpu')->nullable(); + $table->unsignedInteger('ram_mb')->nullable(); + $table->unsignedInteger('disk_gb')->nullable(); + $table->unsignedInteger('bandwidth_gb')->nullable(); + $table->json('specs')->nullable(); + $table->text('description')->nullable(); + $table->unsignedBigInteger('unit_price')->default(0); + $table->timestamps(); + }); + + Schema::table('services', function (Blueprint $table) { + $table->foreignId('custom_product_id')->nullable()->after('product_id') + ->constrained('custom_products')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('services', function (Blueprint $table) { + $table->dropConstrainedForeignId('custom_product_id'); + }); + Schema::dropIfExists('custom_products'); + } +}; diff --git a/routes/client.php b/routes/client.php index c7cabcf..848519c 100644 --- a/routes/client.php +++ b/routes/client.php @@ -122,15 +122,15 @@ Route::prefix('v1') Route::get('hosting', 'indexHosting')->name('hosting.list'); Route::get('web-design', 'indexWebDesign')->name('web-design.list'); Route::get('domain', 'indexDomain')->name('domain.list'); - Route::get('{uuid}', 'getDetail')->name('detail'); - Route::get('{uuid}/vm-password', 'getVmPassword')->middleware('throttle:20,1')->name('vm-password'); - Route::get('{uuid}/console-ticket', 'getConsoleTicket')->name('console-ticket'); + Route::get('{uuid}', 'getDetail')->middleware('seal.response:always')->name('detail'); + Route::get('{uuid}/vm-password', 'getVmPassword')->middleware(['throttle:20,1', 'seal.response:always'])->name('vm-password'); + Route::get('{uuid}/console-ticket', 'getConsoleTicket')->middleware('seal.response:always')->name('console-ticket'); Route::get('{uuid}/available-os', 'getAvailableOs')->name('available-os'); Route::post('{uuid}/rebuild', 'rebuild')->name('rebuild'); Route::patch('{uuid}/auto-renew', 'updateAutoRenew')->name('auto-renew.update'); }); - Route::middleware(['api.auth']) + Route::middleware(['api.auth', 'seal.response:always']) ->get('get-ticket-console', [ClientServiceController::class, 'getTicketByOrderNum']) ->name('console-ticket'); @@ -318,13 +318,14 @@ Route::prefix('v1/system') Route::controller(VmController::class)->group(function () { Route::get('vm-power-state', 'powerState')->name('vm.power-state'); Route::get('vm-metrics', 'metrics')->name('vm.metrics'); + Route::get('vm-metrics/history', 'metricsHistory')->name('vm.metrics.history'); Route::post('vm-power-on', 'powerOn')->name('vm.power-on'); Route::post('vm-power-off', 'powerOff')->name('vm.power-off'); Route::get('vm-ip-options', 'ipOptions')->name('vm.ip-options'); Route::post('vm-change-ip', 'changeIp')->name('vm.change-ip'); Route::post('vm-change-port', 'changePort')->name('vm.change-port'); Route::post('vm-change-password', 'changePassword')->name('vm.change-password'); - Route::get('get-ticket-console', 'consoleTicket')->name('vm.console-ticket'); + Route::get('get-ticket-console', 'consoleTicket')->middleware('seal.response:always')->name('vm.console-ticket'); Route::post('vm-restart', 'restart')->name('vm.restart'); }); diff --git a/tests/Feature/Api/Admin/Customer/CreateCustomServiceTest.php b/tests/Feature/Api/Admin/Customer/CreateCustomServiceTest.php new file mode 100644 index 0000000..d2212a2 --- /dev/null +++ b/tests/Feature/Api/Admin/Customer/CreateCustomServiceTest.php @@ -0,0 +1,69 @@ + 'Full Administrator', 'guard_name' => 'admin']); + $admin = User::factory()->create(); + $admin->assignRole('Full Administrator'); + $customer = Customer::factory()->create(); + + $response = $this->actingAs($admin, 'admin')->postJson( + "/api/v1/administrator/customers/{$customer->uuid}/services", + [ + 'custom' => [ + 'name' => 'VPS riêng', + 'category' => 'vps', + 'unit_price' => 200000, + 'cpu' => 8, + 'ram_mb' => 16384, + 'disk_gb' => 200, + 'specs' => [['label' => 'Vị trí', 'value' => 'HCM']], + ], + 'billing_cycle' => 'quarterly', + 'payment_status' => 'unpaid', + ], + )->assertCreated(); + + $service = Service::findOrFail($response->json('data.id')); + $custom = CustomProduct::findOrFail($service->custom_product_id); + + $this->assertNull($service->product_id); + $this->assertSame('vps', $service->type); + $this->assertSame(16384, $custom->ram_mb); + $this->assertSame(600000, (int) Invoice::where('customer_id', $customer->id)->value('total')); + $this->assertSame( + 1200000, + collect(RenewalPricing::options($custom))->firstWhere('months', 6)['price'], + ); + } + + public function test_requires_product_or_custom(): void + { + Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']); + $admin = User::factory()->create(); + $admin->assignRole('Full Administrator'); + $customer = Customer::factory()->create(); + + $this->actingAs($admin, 'admin') + ->postJson("/api/v1/administrator/customers/{$customer->uuid}/services", ['payment_status' => 'unpaid']) + ->assertStatus(422); + } +} diff --git a/tests/Unit/ResponseCipherTest.php b/tests/Unit/ResponseCipherTest.php new file mode 100644 index 0000000..bb815f2 --- /dev/null +++ b/tests/Unit/ResponseCipherTest.php @@ -0,0 +1,85 @@ + 'application/json'], json_encode($body)); + $request->headers->set('x-seal-key', base64_encode($key ?? $this->key())); + if ($optIn) { + $request->headers->set('x-seal', '1'); + } + + return $request; + } + + public function test_always_mode_seals_with_session_key_and_is_reversible(): void + { + $response = (new SealResponse())->handle( + $this->request(), + fn () => new JsonResponse(['data' => ['password' => 'Cloud226*()']]), + 'always', + ); + + $body = $response->getData(true); + $this->assertArrayNotHasKey('data', $body); + $this->assertStringNotContainsString('Cloud226', $response->getContent()); + $this->assertSame( + ['data' => ['password' => 'Cloud226*()']], + json_decode(ResponseCipher::open($body['enc'], $this->key()), true), + ); + $this->assertNull(ResponseCipher::open($body['enc'], str_repeat('x', 32))); + } + + public function test_passthrough_without_key_or_opt_in(): void + { + $noKey = Request::create('/x'); + $noKey->headers->set('x-seal', '1'); + $plain = (new SealResponse())->handle($noKey, fn () => new JsonResponse(['a' => 1])); + $this->assertSame(['a' => 1], $plain->getData(true)); + + $noOptIn = (new SealResponse())->handle($this->request(), fn () => new JsonResponse(['a' => 1])); + $this->assertSame(['a' => 1], $noOptIn->getData(true)); + } + + public function test_opt_in_seals_errors_and_opens_request_body(): void + { + $sealed = ['enc' => ResponseCipher::seal(json_encode(['name' => 'An']), $this->key())]; + + $response = (new SealResponse())->handle( + $this->request($sealed, optIn: true), + fn (Request $r) => new JsonResponse(['got' => $r->input('name')], 422), + ); + + $this->assertSame(422, $response->getStatusCode()); + $this->assertSame( + ['got' => 'An'], + json_decode(ResponseCipher::open($response->getData(true)['enc'], $this->key()), true), + ); + } + + public function test_request_sealed_with_stale_key_gets_428(): void + { + $sealed = ['enc' => ResponseCipher::seal('{"a":1}', str_repeat('o', 32))]; + + $response = (new SealResponse())->handle( + $this->request($sealed, optIn: true), + fn () => new JsonResponse(['ok' => true]), + ); + + $this->assertSame(428, $response->getStatusCode()); + } +} diff --git a/tests/Unit/VmMetricsHistoryTest.php b/tests/Unit/VmMetricsHistoryTest.php new file mode 100644 index 0000000..e992089 --- /dev/null +++ b/tests/Unit/VmMetricsHistoryTest.php @@ -0,0 +1,32 @@ + 'http://monitor', 'portal-monitor.api_key' => 'k']); + Http::fake(['monitor/*' => Http::response(['data' => [ + ['timestamp' => '2026-10-27T00:00:10Z', 'cpu_usage_percent' => 10, 'guest_memory_usage_percent' => 40], + ['timestamp' => '2026-10-27T00:00:20Z', 'cpu_usage_percent' => 30, 'guest_memory_usage_percent' => null], + ['timestamp' => '2026-10-27T00:30:00Z', 'cpu_usage_percent' => 50], + ]])]); + + $points = app(VmMetricsService::class)->history( + 'vm-1', + new \DateTimeImmutable('2026-10-27T00:00:00Z'), + new \DateTimeImmutable('2026-10-27T01:00:00Z'), + maxPoints: 4, + ); + + $this->assertCount(2, $points); + $this->assertSame(20.0, $points[0]['cpu']); + $this->assertSame(40.0, $points[0]['mem']); + $this->assertNull($points[1]['mem']); + } +}