feat(vm): hoàn tiền khi cấp VM lỗi, theo dõi job queue, uuid dịch vụ cho ticket, chuẩn hoá RAM addon
- VmProvisionException/VmProvisionRefund xử lý lỗi cấp phát VM - QueueJobInspector + QueueMonitorController xem chi tiết job - Thêm service uuid cho ticket, thời lượng cho activity_logs - Migration chuẩn hoá RAM addon sang MB, trạng thái deleting cho snapshot Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
7136bec265
commit
62596d9063
@@ -8,6 +8,7 @@ use App\Http\Resources\Api\Client\Ticket\ClientTicketResource;
|
||||
use App\Jobs\Ticket\TicketJobs;
|
||||
use App\Mail\TicketCreated;
|
||||
use App\Mail\Admin\TicketAdminNotification;
|
||||
use App\Models\Service;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\Notification;
|
||||
@@ -69,6 +70,15 @@ class TicketController extends Controller
|
||||
/**
|
||||
* Create a new ticket for the authenticated customer.
|
||||
*/
|
||||
private function assertServiceTicketable(string $serviceUuid, int $customerId): void
|
||||
{
|
||||
$failed = Service::where('uuid', $serviceUuid)->where('customer_id', $customerId)->where('status', 'failed')->exists();
|
||||
abort_unless($failed, 404, 'Dịch vụ không tồn tại.');
|
||||
|
||||
$hasOpenTicket = Ticket::where('service_uuid', $serviceUuid)->whereIn('status', Ticket::UNRESOLVED_STATUSES)->exists();
|
||||
abort_if($hasOpenTicket, 422, 'Dịch vụ này đã có ticket đang được xử lý.');
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
@@ -76,11 +86,17 @@ class TicketController extends Controller
|
||||
'description' => ['required', 'string', 'min:20'],
|
||||
'room' => ['nullable', 'string'],
|
||||
'data' => ['nullable', 'array'],
|
||||
'service_uuid' => ['nullable', 'uuid'],
|
||||
'images' => ['nullable', 'array', 'max:5'],
|
||||
'images.*' => ['image', 'mimes:jpg,jpeg,png,gif,webp', 'max:5120'],
|
||||
]);
|
||||
|
||||
$customer = auth('client')->user();
|
||||
$serviceUuid = $request->input('service_uuid');
|
||||
|
||||
if ($serviceUuid) {
|
||||
$this->assertServiceTicketable($serviceUuid, $customer->id);
|
||||
}
|
||||
|
||||
$ticketNum = 'TKT-' . now()->format('Ymd') . '-' . strtoupper(Str::random(5));
|
||||
|
||||
@@ -96,6 +112,7 @@ class TicketController extends Controller
|
||||
'data' => $request->input('data') ? json_encode($request->input('data')) : null,
|
||||
'gallery' => $gallery ?: null,
|
||||
'customer_id' => $customer->id,
|
||||
'service_uuid' => $serviceUuid,
|
||||
]);
|
||||
|
||||
TicketJobs::dispatch($ticket, $customer);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\QueueJobInspector;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Laravel\Horizon\Contracts\JobRepository;
|
||||
use Laravel\Horizon\Contracts\MetricsRepository;
|
||||
@@ -71,6 +72,14 @@ class QueueMonitorController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(string $id): JsonResponse
|
||||
{
|
||||
$job = $this->jobs->getJobs([$id])->first();
|
||||
abort_if(!$job, 404, "Không tìm thấy job #{$id}");
|
||||
|
||||
return response()->json(['success' => true, 'data' => QueueJobInspector::describe($job)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/queue/jobs/failed/{id}/retry
|
||||
* Re-queue a failed job by its Horizon job ID.
|
||||
|
||||
@@ -8,9 +8,44 @@ use App\Models\Os;
|
||||
use App\Services\VmProvisionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
trait ManagesVmAccess
|
||||
{
|
||||
private const CLICK_LABELS = [
|
||||
'snapshot' => 'Snapshot',
|
||||
'change-password' => 'Đổi mật khẩu',
|
||||
'change-ip' => 'Đổi IP',
|
||||
'change-port' => 'Đổi Remote Port',
|
||||
'renew' => 'Gia hạn',
|
||||
'console' => 'Truy cập console',
|
||||
'rebuild' => 'Rebuild máy chủ',
|
||||
'addons' => 'Addons',
|
||||
];
|
||||
|
||||
public function actionClick(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'product' => 'required',
|
||||
'action' => ['required', 'string', Rule::in(array_keys(self::CLICK_LABELS))],
|
||||
]);
|
||||
|
||||
$vm = $this->resolveVmById($data['product'], $request);
|
||||
if ($vm instanceof JsonResponse) {
|
||||
return $vm;
|
||||
}
|
||||
|
||||
$this->activityLogger->log(
|
||||
event: 'click_' . str_replace('-', '_', $data['action']),
|
||||
logName: 'VM',
|
||||
description: "Customer {$request->user()->email} bấm \"" . self::CLICK_LABELS[$data['action']] . "\" trên VM: {$vm->name} (ID: {$vm->vmware_id})",
|
||||
subject: $vm,
|
||||
newValues: ['action' => $data['action']],
|
||||
);
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function consoleTicket(Request $request, vCenter $serviceVCenter): JsonResponse
|
||||
{
|
||||
$productId = $request->get('product');
|
||||
@@ -53,7 +88,7 @@ trait ManagesVmAccess
|
||||
$validated = $request->validate([
|
||||
'product' => ['required'],
|
||||
'current_password' => ['required', 'string'],
|
||||
'new_password' => ['required', 'string', 'min:8'],
|
||||
'new_password' => ['required', 'string', 'min:12', 'max:64', 'regex:/^[A-Za-z0-9!@#^*_+=?.,:~]+$/'],
|
||||
]);
|
||||
|
||||
$vm = $this->resolveVmById($validated['product'], $request);
|
||||
|
||||
@@ -33,6 +33,7 @@ trait ManagesVmPower
|
||||
return $this->vmLocked();
|
||||
}
|
||||
|
||||
$startedAt = now();
|
||||
try {
|
||||
$result = $action->execute($vm);
|
||||
} finally {
|
||||
@@ -44,6 +45,8 @@ trait ManagesVmPower
|
||||
logName: 'VM',
|
||||
description: "Customer {$request->user()->email} bật VM: {$vm->name} (ID: {$vm->vmware_id})",
|
||||
subject: $vm,
|
||||
startedAt: $startedAt,
|
||||
finishedAt: now(),
|
||||
);
|
||||
|
||||
return $this->vmResponse($result, 'Đã gửi lệnh bật VM.');
|
||||
@@ -71,6 +74,7 @@ trait ManagesVmPower
|
||||
return $this->vmLocked();
|
||||
}
|
||||
|
||||
$startedAt = now();
|
||||
try {
|
||||
$result = $action->execute($vm);
|
||||
} finally {
|
||||
@@ -82,6 +86,8 @@ trait ManagesVmPower
|
||||
logName: 'VM',
|
||||
description: "Customer {$request->user()->email} tắt VM: {$vm->name} (ID: {$vm->vmware_id})",
|
||||
subject: $vm,
|
||||
startedAt: $startedAt,
|
||||
finishedAt: now(),
|
||||
);
|
||||
|
||||
return $this->vmResponse($result, 'Đã gửi lệnh tắt VM.');
|
||||
@@ -104,6 +110,7 @@ trait ManagesVmPower
|
||||
return $this->vmLocked();
|
||||
}
|
||||
|
||||
$startedAt = now();
|
||||
try {
|
||||
$result = $serviceVCenter->restartVM($vm, $vm->vmware_id);
|
||||
} finally {
|
||||
@@ -122,6 +129,8 @@ trait ManagesVmPower
|
||||
logName: 'VM',
|
||||
description: "Customer {$request->user()->email} restart VM: {$vm->name} (ID: {$vm->vmware_id})",
|
||||
subject: $vm,
|
||||
startedAt: $startedAt,
|
||||
finishedAt: now(),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Jobs\VirtualMachine\Snapshot\CreateSnapshot;
|
||||
use App\Jobs\VirtualMachine\Snapshot\RevertSnapshot;
|
||||
use App\Jobs\VirtualMachine\Snapshot\RemoveSnapshot;
|
||||
use App\Models\ModuleSnapshot;
|
||||
use App\Models\ModuleVmware;
|
||||
use App\Services\ActivityLogger;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -25,6 +26,10 @@ class SnapshotController extends Controller
|
||||
$payload = $request->validate([
|
||||
'product' => ['required'],
|
||||
'description' => ['nullable', 'string', 'max:1000'],
|
||||
'name' => ['nullable', 'string', 'max:80', 'regex:/^[A-Za-z0-9._-]+$/'],
|
||||
], [
|
||||
'name.regex' => 'Tên snapshot chỉ gồm chữ không dấu, số và các ký tự . _ -',
|
||||
'name.max' => 'Tên snapshot tối đa 80 ký tự.',
|
||||
]);
|
||||
|
||||
$vm = $this->resolveVmById($payload['product'], $request);
|
||||
@@ -32,11 +37,10 @@ class SnapshotController extends Controller
|
||||
return $vm;
|
||||
}
|
||||
|
||||
$today = now()->format('d-m-Y');
|
||||
$existingCount = ModuleSnapshot::where('module_vmware_id', $vm->id)
|
||||
->whereDate('created_at', today())
|
||||
->count();
|
||||
$snapshotName = sprintf('%s-%s-%d', $vm->name, $today, $existingCount + 1);
|
||||
$snapshotName = $payload['name'] ?? $this->suggestName($vm);
|
||||
if (ModuleSnapshot::where('module_vmware_id', $vm->id)->where('name', $snapshotName)->exists()) {
|
||||
return response()->json(['error' => true, 'message' => 'Tên snapshot đã tồn tại, vui lòng chọn tên khác.'], 422);
|
||||
}
|
||||
|
||||
$snapshot = ModuleSnapshot::create([
|
||||
'name' => $snapshotName,
|
||||
@@ -69,6 +73,18 @@ class SnapshotController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
private function suggestName(ModuleVmware $vm): string
|
||||
{
|
||||
$prefix = sprintf('%s-%s-', $vm->name, now()->format('d-m-Y'));
|
||||
$sequence = ModuleSnapshot::where('module_vmware_id', $vm->id)->whereDate('created_at', today())->count() + 1;
|
||||
|
||||
while (ModuleSnapshot::where('module_vmware_id', $vm->id)->where('name', $prefix . $sequence)->exists()) {
|
||||
$sequence++;
|
||||
}
|
||||
|
||||
return $prefix . $sequence;
|
||||
}
|
||||
|
||||
public function index(Request $request, vCenter $serviceVCenter): JsonResponse
|
||||
{
|
||||
$productParam = $request->query('product');
|
||||
@@ -116,6 +132,9 @@ class SnapshotController extends Controller
|
||||
];
|
||||
|
||||
if ($snapshot) {
|
||||
if ($snapshot->status === 'deleting') {
|
||||
unset($payload['status']);
|
||||
}
|
||||
$snapshot->update($payload);
|
||||
} else {
|
||||
ModuleSnapshot::create($payload);
|
||||
@@ -142,6 +161,7 @@ class SnapshotController extends Controller
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'data' => $dbSnapshots,
|
||||
'suggested_name' => $this->suggestName($vm),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -204,7 +224,17 @@ class SnapshotController extends Controller
|
||||
return response()->json(['error' => true, 'message' => 'Không tìm thấy snapshot'], 404);
|
||||
}
|
||||
|
||||
RemoveSnapshot::dispatch($vm->id, $snapshot->id);
|
||||
if ($snapshot->status === 'deleting') {
|
||||
return response()->json(['error' => true, 'message' => 'Snapshot đang được xóa.'], 422);
|
||||
}
|
||||
|
||||
$deletedNow = $snapshot->status === 'failed';
|
||||
if ($deletedNow) {
|
||||
$snapshot->delete();
|
||||
} else {
|
||||
$snapshot->update(['status' => 'deleting']);
|
||||
RemoveSnapshot::dispatch($vm->id, $snapshot->id);
|
||||
}
|
||||
|
||||
$this->activityLogger->log(
|
||||
event: 'deleted',
|
||||
@@ -215,7 +245,7 @@ class SnapshotController extends Controller
|
||||
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'message' => 'Đã gửi yêu cầu xóa snapshot vào hàng đợi',
|
||||
'message' => $deletedNow ? 'Đã xóa snapshot' : 'Đã gửi yêu cầu xóa snapshot vào hàng đợi',
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user