feat: add bulk delete with validation to IP Pool and update provisioning logic and email notifications

This commit is contained in:
pbao9
2026-05-13 15:41:56 +07:00
parent 1bbba4e158
commit ae3b681aaa
8 changed files with 242 additions and 101 deletions
@@ -240,6 +240,42 @@ class IpPoolController extends Controller
return response()->json(['error' => false, 'message' => 'Đã xoá IP Pool thành công']);
}
public function bulkDelete(Request $request): JsonResponse
{
$request->validate([
'ids' => 'required|array|min:1',
'ids.*' => 'integer',
]);
$ids = $request->ids;
$pools = IpPool::whereIn('id', $ids)->get();
if ($pools->isEmpty()) {
return response()->json(['error' => true, 'message' => 'Không tìm thấy IP Pool nào'], 404);
}
// Block deletion if any IP is currently assigned
$inUseIps = ModuleIpAddress::whereIn('ip', $pools->pluck('ip_address'))
->where('status', 1)
->pluck('ip')
->toArray();
if (!empty($inUseIps)) {
return response()->json([
'error' => true,
'message' => 'Không thể xoá — các IP sau đang được gán cho máy chủ: ' . implode(', ', $inUseIps),
], 422);
}
$deleted = IpPool::whereIn('id', $ids)->delete();
return response()->json([
'error' => false,
'message' => "Đã xoá {$deleted} IP Pool thành công",
'deleted' => $deleted,
]);
}
public function networks(): JsonResponse
{
$networks = ModuleNetwork::select(['id', 'name', 'type', 'network'])
@@ -9,6 +9,7 @@ use App\Models\IpPool;
use App\Models\ModuleIpAddress;
use App\Models\ModuleVmware;
use App\Models\Os;
use App\Http\Services\vCenter as vCenterService;
use App\Models\vCenter;
use App\Services\VsphereHostSshService;
use Illuminate\Bus\Queueable;
@@ -29,13 +30,12 @@ use Illuminate\Support\Facades\Log;
* [1] Resolve network config from order item → product → ipAddress template
* [2] for attempt 1..MAX_ATTEMPTS:
* [3] Pick a free IP with DB transaction + lock (reserveIp)
* [4] Pre-check: ping IP — if reachable → release and try next
* [5] Call assignIPLinux.ps1 / assignIPWindows.ps1 via SSH
* [6] On FAIL → release IP, try next
* [7] Call verifyIPLinux.ps1 / verifyIPWindows.ps1 via SSH
* [8] On FAIL → release IP, try next
* [9] SUCCESS → persistIp() → dispatch SendVmCredentialsEmailJob
* [10] All attempts exhausted → mark VM inactive (status=0)
* [4] Call assignIPLinux.ps1 / assignIPWindows.ps1 via SSH
* [5] On FAIL → release IP, try next
* [6] Call verifyIPLinux.ps1 / verifyIPWindows.ps1 via SSH
* [7] On FAIL → release IP, try next
* [8] SUCCESS → persistIp() → dispatch SendVmCredentialsEmailJob
* [9] All attempts exhausted → mark VM inactive (status=0)
*/
class AssignIpToGuestJob implements ShouldQueue
{
@@ -58,7 +58,7 @@ class AssignIpToGuestJob implements ShouldQueue
// ─────────────────────────────────────────────────────────────────────────
public function handle(VsphereHostSshService $ssh, vCenter $vCenterService): void
public function handle(VsphereHostSshService $ssh, vCenterService $vCenterService): void
{
Log::info('AssignIpToGuestJob: start', [
'module_vmware_id' => $this->moduleVmwareId,
@@ -118,16 +118,6 @@ class AssignIpToGuestJob implements ShouldQueue
$ip = $reservation->ip;
// [4] Pre-check: skip IPs that are already reachable (in use by another host)
if ($this->isIpReachable($ip)) {
Log::warning('AssignIpToGuestJob: IP reachable (likely in use), skipping', [
'ip' => $ip,
'attempt' => $attempt,
]);
$this->releaseIp($reservation);
continue;
}
// [5] Assign IP into VM guest OS via PS1 script
$assignOutput = $ssh->assignIp(
vmId: $this->vmId,
@@ -339,16 +329,6 @@ class AssignIpToGuestJob implements ShouldQueue
// Micro-helpers
// ─────────────────────────────────────────────────────────────────────────
/**
* Ping the IP from the Laravel host (1 packet, 1 s timeout).
* Returns true if reachable → the IP is already in use by something else.
*/
private function isIpReachable(string $ip): bool
{
exec(sprintf('ping -c 1 -W 1 %s 2>/dev/null', escapeshellarg($ip)), $output, $exitCode);
return $exitCode === 0;
}
/** PS1 scripts output exactly "SUCCESS" or "FAIL". */
private function isSuccess(string $output): bool
{
@@ -64,19 +64,23 @@ class SendVmCredentialsEmailJob implements ShouldQueue
$orderItem = $vm->order?->orderItems?->first();
$months = (int) ($orderItem?->months ?? 0);
$osGroupName = $vm->os?->osGroup?->name ?? '';
$isWindows = str_contains(strtolower($osGroupName), 'windows');
$osUsername = $isWindows ? 'Administrator' : 'root';
$emailData = [
'company_name' => $settingValue->company ?? 'Thế Giới Số',
'company_logo' => $settingValue->logo ?? '',
'client_name' => trim($customer->firstname . ' ' . $customer->lastname),
'client_name' => $customer->fullname ?? '',
'product_name' => $orderItem?->getProductAttribute()?->name ?? 'Cloud VPS',
'product_billing' => $this->billingLabel($months),
'product_price' => $orderItem ? number_format((float) $orderItem->price, 0, ',', '.') : 'N/A',
'service_host' => $vm->ip ?? 'N/A',
'service_username' => $vm->user,
'service_username' => $osUsername,
'service_port' => $vm->port,
'service_password' => $vm->plain_password ?? '(xem tài khoản quản lý)',
'os_name' => $vm->os
? (($vm->os->osGroup?->name ?? '') . ' ' . $vm->os->version)
? (($osGroupName . ' ' . $vm->os->version))
: 'Unknown OS',
'service_due_exp' => $vm->next_due_date
? date('d/m/Y', (int) $vm->next_due_date)
@@ -117,7 +121,7 @@ class SendVmCredentialsEmailJob implements ShouldQueue
// Notify admin with VM credentials after successful provisioning
$order = $vm->order;
AdminOrderNotificationJob::dispatch([
'customer_name' => trim($customer->firstname . ' ' . $customer->lastname),
'customer_name' => $customer->fullname ?? '',
'customer_email' => $customer->email,
'client_ip' => null,
'order_num' => $order?->order_num ?? $order?->id ?? 'N/A',
@@ -127,7 +131,7 @@ class SendVmCredentialsEmailJob implements ShouldQueue
'total' => $orderItem?->price ?? 0,
'created_at' => $order?->created_at?->format('d/m/Y H:i:s') ?? now()->format('d/m/Y H:i:s'),
'ip' => $vm->ip ?? '',
'username' => $vm->user ?? '',
'username' => $osUsername,
'password' => $vm->plain_password ?? '',
]);
+2 -1
View File
@@ -4,10 +4,11 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class IpPool extends Model
{
use HasFactory;
use HasFactory, SoftDeletes;
protected $fillable = [
'ip_address',
@@ -0,0 +1,21 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('ip_pools', function (Blueprint $table) {
$table->softDeletes();
});
}
public function down(): void
{
Schema::table('ip_pools', function (Blueprint $table) {
$table->dropSoftDeletes();
});
}
};
@@ -5,39 +5,46 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Thông báo đơn hàng mới</title>
<style>
body { font-family: Arial, sans-serif; background: #f4f4f4; margin: 0; padding: 0; }
.wrapper { max-width: 640px; margin: 30px auto; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.header { background: #1e40af; padding: 24px 32px; }
.header h1 { color: #fff; margin: 0; font-size: 20px; }
.header span { color: #bfdbfe; font-size: 13px; }
.body { padding: 32px; color: #374151; }
.body p { line-height: 1.7; margin: 0 0 12px; }
.section-title { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #6b7280; margin: 24px 0 10px; }
.info-box { background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 6px; padding: 16px 20px; }
.info-box table { width: 100%; border-collapse: collapse; }
.info-box td { padding: 5px 0; font-size: 14px; vertical-align: top; }
.info-box td:first-child { color: #6b7280; width: 160px; white-space: nowrap; }
.info-box td:last-child { font-weight: 600; color: #111827; }
* { box-sizing: border-box; }
body { font-family: 'Segoe UI', Arial, sans-serif; background: #f0f4f8; margin: 0; padding: 0; }
.wrapper { max-width: 620px; margin: 30px auto; }
.card { background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 20px rgba(0,0,0,0.08); }
.header { background: linear-gradient(135deg, #1e40af 0%, #1d4ed8 100%); padding: 36px 40px; text-align: center; }
.header .icon { font-size: 48px; margin-bottom: 12px; }
.header h1 { color: #fff; margin: 0 0 6px; font-size: 22px; font-weight: 700; }
.header p { color: #bfdbfe; margin: 0; font-size: 14px; }
.body { padding: 36px 40px; }
.section-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #9ca3af; margin: 24px 0 10px; }
.section-label:first-child { margin-top: 0; }
.info-table { width: 100%; border-collapse: collapse; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; }
.info-table td { padding: 10px 16px; font-size: 14px; border-bottom: 1px solid #f3f4f6; }
.info-table tr:last-child td { border-bottom: none; }
.info-table td:first-child { color: #6b7280; width: 45%; }
.info-table td:last-child { font-weight: 600; color: #111827; }
.badge { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; }
.badge-blue { background: #dbeafe; color: #1e40af; }
.badge-green { background: #dcfce7; color: #15803d; }
.divider { border: none; border-top: 1px solid #e5e7eb; margin: 24px 0; }
.footer { background: #f9fafb; border-top: 1px solid #e5e7eb; padding: 16px 32px; text-align: center; font-size: 12px; color: #9ca3af; }
.badge-blue { background: #dbeafe; color: #1e40af; }
.amount-row td:last-child { font-size: 16px; font-weight: 800; color: #1e40af; }
.vm-box { background: #f0fdf4; border: 1.5px solid #86efac; border-radius: 10px; padding: 20px 24px; margin-top: 10px; }
.vm-row { display: flex; align-items: center; padding: 8px 0; border-bottom: 1px solid #dcfce7; }
.vm-row:last-child { border-bottom: none; }
.vm-label { color: #15803d; font-size: 13px; width: 130px; flex-shrink: 0; }
.vm-value { font-family: 'Courier New', monospace; font-size: 14px; font-weight: 700; color: #111827; word-break: break-all; }
.footer { text-align: center; padding: 20px 40px; border-top: 1px solid #f3f4f6; font-size: 12px; color: #9ca3af; }
</style>
</head>
<body>
<div class="wrapper">
<div class="header">
<h1>📦 Đơn hàng mới đã được thanh toán</h1>
<span>Thông báo tự động từ hệ thống quản lý</span>
</div>
<div class="card">
<div class="header">
<div class="icon">📦</div>
<h1>Đơn hàng mới đã thanh toán</h1>
<p>Đơn #{{ $order_num ?? 'N/A' }} — {{ $created_at ?? now()->format('d/m/Y H:i:s') }}</p>
</div>
<div class="body">
<p>Một đơn hàng mới vừa được khách hàng tạo và thanh toán thành công trên hệ thống.</p>
<div class="section-title">Thông tin khách hàng</div>
<div class="info-box">
<table>
<div class="body">
<div class="section-label">Thông tin khách hàng</div>
<table class="info-table">
<tr>
<td>Tên khách hàng</td>
<td>{{ $customer_name ?? 'N/A' }}</td>
@@ -46,16 +53,16 @@
<td>Email</td>
<td>{{ $customer_email ?? 'N/A' }}</td>
</tr>
@if(!empty($client_ip))
<tr>
<td>IP đặt hàng</td>
<td>{{ $client_ip ?? 'N/A' }}</td>
<td>{{ $client_ip }}</td>
</tr>
@endif
</table>
</div>
<div class="section-title">Thông tin đơn hàng</div>
<div class="info-box">
<table>
<div class="section-label">Thông tin đơn hàng</div>
<table class="info-table">
<tr>
<td>Mã đơn hàng</td>
<td>#{{ $order_num ?? 'N/A' }}</td>
@@ -72,55 +79,45 @@
<td>Chu kỳ thanh toán</td>
<td>{{ $billing_cycle ?? 'N/A' }}</td>
</tr>
<tr>
<tr class="amount-row">
<td>Tổng tiền</td>
<td><strong>{{ number_format($total ?? 0, 0, ',', '.') }} VNĐ</strong></td>
<td>{{ number_format($total ?? 0, 0, ',', '.') }} VNĐ</td>
</tr>
<tr>
<td>Trạng thái</td>
<td><span class="badge badge-green">Đã thanh toán</span></td>
</tr>
<tr>
<td>Thời gian</td>
<td>{{ $created_at ?? now()->format('d/m/Y H:i:s') }}</td>
<td><span class="badge badge-green">✅ Đã thanh toán</span></td>
</tr>
</table>
</div>
@if(!empty($username) || !empty($password) || !empty($ip))
<div class="section-title">Thông tin dịch vụ</div>
<div class="info-box">
<table>
@if(!empty($ip) || !empty($username) || !empty($password))
<div class="section-label">Thông tin dịch vụ Cloud VPS</div>
<div class="vm-box">
@if(!empty($ip))
<div class="vm-row">
<span class="vm-label">🌐 IP Address</span>
<span class="vm-value">{{ $ip }}</span>
</div>
@endif
@if(!empty($username))
<tr>
<td>Username</td>
<td>{{ $username }}</td>
</tr>
<div class="vm-row">
<span class="vm-label">👤 Username</span>
<span class="vm-value">{{ $username }}</span>
</div>
@endif
@if(!empty($password))
<tr>
<td>Password</td>
<td>{{ $password }}</td>
</tr>
<div class="vm-row">
<span class="vm-label">🔑 Password</span>
<span class="vm-value">{{ $password }}</span>
</div>
@endif
@if(!empty($ip))
<tr>
<td>IP Address</td>
<td>{{ $ip }}</td>
</tr>
@endif
</table>
</div>
@endif
</div>
@endif
<hr class="divider">
<p style="font-size:13px;color:#6b7280;">
Email này được gửi tự động bởi hệ thống. Vui lòng không trả lời email này.
</p>
</div>
<div class="footer">
© {{ date('Y') }} {{ $company_name ?? 'Portal Admin' }} — Hệ thống quản lý dịch vụ
<div class="footer">
© {{ date('Y') }} {{ $company_name ?? 'Portal Admin' }} — Hệ thống quản lý dịch vụ<br>
Email này được gửi tự động. Vui lòng không trả lời.
</div>
</div>
</div>
</body>
@@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Đặt hàng thành công</title>
<style>
* { box-sizing: border-box; }
body { font-family: 'Segoe UI', Arial, sans-serif; background: #f0f4f8; margin: 0; padding: 0; }
.wrapper { max-width: 620px; margin: 30px auto; }
.card { background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 20px rgba(0,0,0,0.08); }
.header { background: linear-gradient(135deg, #16a34a 0%, #15803d 100%); padding: 36px 40px; text-align: center; }
.header .icon { font-size: 48px; margin-bottom: 12px; }
.header h1 { color: #fff; margin: 0 0 6px; font-size: 24px; font-weight: 700; }
.header p { color: #bbf7d0; margin: 0; font-size: 14px; }
.body { padding: 36px 40px; }
.greeting { font-size: 16px; color: #374151; margin-bottom: 20px; line-height: 1.6; }
.section-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #9ca3af; margin: 28px 0 10px; }
.info-table { width: 100%; border-collapse: collapse; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; }
.info-table td { padding: 10px 16px; font-size: 14px; border-bottom: 1px solid #f3f4f6; }
.info-table tr:last-child td { border-bottom: none; }
.info-table td:first-child { color: #6b7280; width: 45%; }
.info-table td:last-child { font-weight: 600; color: #111827; }
.vm-box { background: #f0fdf4; border: 1.5px solid #86efac; border-radius: 10px; padding: 20px 24px; margin-top: 12px; }
.vm-box .vm-row { display: flex; align-items: center; padding: 8px 0; border-bottom: 1px solid #dcfce7; }
.vm-box .vm-row:last-child { border-bottom: none; }
.vm-box .vm-label { color: #15803d; font-size: 13px; width: 120px; flex-shrink: 0; }
.vm-box .vm-value { font-family: 'Courier New', monospace; font-size: 14px; font-weight: 700; color: #111827; word-break: break-all; }
.amount-box { background: #fffbeb; border: 1.5px solid #fbbf24; border-radius: 8px; padding: 14px 20px; display: flex; justify-content: space-between; align-items: center; margin-top: 20px; }
.amount-box span { font-size: 13px; color: #92400e; }
.amount-box strong { font-size: 20px; font-weight: 800; color: #b45309; }
.notice { background: #eff6ff; border-left: 3px solid #3b82f6; padding: 12px 16px; border-radius: 0 6px 6px 0; font-size: 13px; color: #1e40af; margin-top: 24px; line-height: 1.6; }
.footer { text-align: center; padding: 20px 40px; border-top: 1px solid #f3f4f6; font-size: 12px; color: #9ca3af; }
</style>
</head>
<body>
<div class="wrapper">
<div class="card">
<div class="header">
<div class="icon">✅</div>
<h1>Đặt hàng thành công!</h1>
<p>Đơn hàng #{{ $orderNum ?? 'N/A' }} đã được xác nhận và thanh toán</p>
</div>
<div class="body">
<p class="greeting">
Xin chào <strong>{{ $customerName ?? 'Quý khách' }}</strong>,<br>
Cảm ơn bạn đã tin tưởng sử dụng dịch vụ của chúng tôi. Đơn hàng của bạn đã được xác nhận và đang trong quá trình khởi tạo.
</p>
<div class="section-label">Thông tin đơn hàng</div>
<table class="info-table">
<tr><td>Mã đơn hàng</td><td>#{{ $orderNum ?? 'N/A' }}</td></tr>
<tr><td>Tổng thanh toán</td><td>{{ number_format($amount ?? 0, 0, ',', '.') }} VNĐ</td></tr>
</table>
@if(!empty($vmResults))
<div class="section-label">Thông tin dịch vụ Cloud VPS</div>
@foreach($vmResults as $vm)
<div class="vm-box">
@if(!empty($vm['ip']))
<div class="vm-row">
<span class="vm-label">🌐 IP Address</span>
<span class="vm-value">{{ $vm['ip'] }}</span>
</div>
@endif
@if(!empty($vm['username']))
<div class="vm-row">
<span class="vm-label">👤 Username</span>
<span class="vm-value">{{ $vm['username'] }}</span>
</div>
@endif
@if(!empty($vm['password']))
<div class="vm-row">
<span class="vm-label">🔑 Password</span>
<span class="vm-value">{{ $vm['password'] }}</span>
</div>
@endif
@if(!empty($vm['os']))
<div class="vm-row">
<span class="vm-label">💿 Hệ điều hành</span>
<span class="vm-value">{{ $vm['os'] }}</span>
</div>
@endif
</div>
@endforeach
@endif
<div class="notice">
⚠️ Vui lòng <strong>đổi mật khẩu ngay</strong> sau khi đăng nhập lần đầu để bảo mật tài khoản.<br>
Nếu cần hỗ trợ, liên hệ: <strong>{{ $supportEmail ?? '' }}</strong> hoặc <strong>{{ $supportPhone ?? '' }}</strong>
</div>
</div>
<div class="footer">
Email này được gửi tự động. Vui lòng không trả lời email này.
</div>
</div>
</div>
</body>
</html>
+1
View File
@@ -319,6 +319,7 @@ Route::prefix('v1')
Route::get('networks', 'networks')->middleware('perm:module-vcenter.view');
Route::get('/', 'index')->middleware('perm:module-vcenter.view');
Route::post('/', 'store')->middleware('perm:module-vcenter.manager');
Route::delete('bulk', 'bulkDelete')->middleware('perm:module-vcenter.manager');
Route::get('{id}', 'show')->middleware('perm:module-vcenter.view');
Route::put('{id}', 'update')->middleware('perm:module-vcenter.manager');
Route::delete('{id}', 'destroy')->middleware('perm:module-vcenter.manager');