feat(service): custom product, lịch sử giám sát VM và mã hoá response theo phiên

- 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 <noreply@anthropic.com>
This commit is contained in:
baodev0
2026-10-27 13:58:50 +07:00
co-authored by Claude Sonnet 5.5
parent 8108e8fe53
commit 6315a392c0
19 changed files with 549 additions and 33 deletions
@@ -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',
@@ -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]);
}
}
+2
View File
@@ -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,
];
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Http\Middleware;
use App\Support\ResponseCipher;
use Closure;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class SealResponse
{
public const KEY_HEADER = 'x-seal-key';
public const OPT_IN_HEADER = 'x-seal';
private const SEALED_ATTRIBUTE = 'response_sealed';
public function handle(Request $request, Closure $next, ?string $mode = null)
{
$key = ResponseCipher::keyFrom($request->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;
}
}
@@ -47,8 +47,17 @@ class ServiceClientDetailResource extends JsonResource
*
* @return array<string, mixed>
*/
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,
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Models;
use App\Support\BillingCycleCatalog;
use Illuminate\Database\Eloquent\Model;
class CustomProduct extends Model
{
public const CATEGORIES = ['vps', 'hosting', 'ssl', 'server_rental', 'other'];
public const CYCLE_MONTHS = [1, 3, 6, 12, 24, 36];
protected $fillable = [
'name', 'category', 'cpu', 'ram_mb', 'disk_gb', 'bandwidth_gb',
'specs', 'description', 'unit_price',
];
protected $casts = ['specs' => '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,
];
}
}
+6
View File
@@ -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()
{
+3 -3
View File
@@ -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,
@@ -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']);
});
}
+4 -3
View File
@@ -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;
}
+11 -2
View File
@@ -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');
}
+37 -1
View File
@@ -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();
}
}
+13 -2
View File
@@ -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<array{billing_cycle: string, months: int, price: int}> */
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) {
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Support;
class ResponseCipher
{
private const CIPHER = 'aes-256-gcm';
public static function keyFrom(?string $encoded): ?string
{
$key = $encoded ? base64_decode($encoded, true) : false;
return $key !== false && strlen($key) === 32 ? $key : null;
}
public static function seal(string $plain, string $key): string
{
$iv = random_bytes(12);
$cipherText = openssl_encrypt($plain, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv, $tag);
return base64_encode($iv . $cipherText . $tag);
}
public static function open(string $sealed, string $key): ?string
{
$raw = base64_decode($sealed, true);
if ($raw === false || strlen($raw) < 29) {
return null;
}
$plain = openssl_decrypt(
substr($raw, 12, -16),
self::CIPHER,
$key,
OPENSSL_RAW_DATA,
substr($raw, 0, 12),
substr($raw, -16),
);
return $plain === false ? null : $plain;
}
}
@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('custom_products', function (Blueprint $table) {
$table->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');
}
};
+6 -5
View File
@@ -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');
});
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Admin\Customer;
use App\Models\Customer;
use App\Models\CustomProduct;
use App\Models\Invoice;
use App\Models\Service;
use App\Models\User;
use App\Support\RenewalPricing;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class CreateCustomServiceTest extends TestCase
{
use RefreshDatabase;
public function test_custom_service_stores_own_product_and_prices_by_cycle(): void
{
Role::firstOrCreate(['name' => '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);
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace Tests\Unit;
use App\Http\Middleware\SealResponse;
use App\Support\ResponseCipher;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Tests\TestCase;
class ResponseCipherTest extends TestCase
{
private function key(): string
{
return str_repeat('k', 32);
}
private function request(array $body = [], bool $optIn = false, ?string $key = null): Request
{
$request = Request::create('/x', 'POST', [], [], [], ['CONTENT_TYPE' => '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());
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace Tests\Unit;
use App\Services\VmMetricsService;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class VmMetricsHistoryTest extends TestCase
{
public function test_history_is_bucketed_and_averaged(): void
{
config(['portal-monitor.url' => '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']);
}
}