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:
co-authored by
Claude Sonnet 5.5
parent
8108e8fe53
commit
6315a392c0
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user