From 8a115c5175e345f229e80b52e3019f3b82c14fe1 Mon Sep 17 00:00:00 2001 From: pbao9 Date: Sat, 16 May 2026 16:52:21 +0700 Subject: [PATCH] refactor: implement centralized cache key management and add utility traits for vCenter SSH operations. --- app/Http/.DS_Store | Bin 8196 -> 0 bytes .../EmailTemplate/EmailTemplateController.php | 78 ++++ .../InfraManagementController.php | 161 ++++++++ .../Api/Admin/Setting/SettingController.php | 3 +- .../Api/Admin/VPS/VPSController.php | 8 +- .../Controllers/Api/Service/VmController.php | 91 +++++ .../Controllers/Backend/DomainController.php | 13 +- .../Controllers/admin/GroupController.php | 19 +- .../admin/VCenterManagerController.php | 99 +++++ app/Http/Services/vCenter.php | 352 +++++------------- .../Services/vCenter/Concerns/ManagesSsh.php | 231 ++++++++++++ app/Http/Services/vCenter/SnapshotService.php | 42 +-- .../VirtualMachine/ChangeVmPasswordJob.php | 99 +++++ .../Handle/WaitStatusVirtualMachine.php | 2 +- app/Jobs/VirtualMachine/Power/RebootVmJob.php | 2 +- app/Jobs/VirtualMachine/Power/StartVmJob.php | 2 +- app/Jobs/VirtualMachine/Power/StopVmJob.php | 2 +- .../VirtualMachine/Sync/UpdateVMInfoJob.php | 2 +- app/Providers/AppServiceProvider.php | 5 +- app/Providers/MailConfigServiceProvider.php | 3 +- .../Addon/AddonCachedRepository.php | 24 +- app/Repositories/BaseCachedRepository.php | 94 +++-- .../Customer/CustomerCachedRepository.php | 33 +- .../Discount/DiscountCachedRepository.php | 29 +- .../Group/GroupCachedRepository.php | 24 +- .../Whois/CachedWhoisRepository.php | 49 ++- .../vCenter/vCenterCachedRepository.php | 23 +- app/Services/Cache/CacheService.php | 143 +++++-- app/Services/Cache/CacheServiceInterface.php | 48 ++- app/Services/StudentService.php | 100 +++++ app/Services/VsphereHostSshService.php | 81 +++- app/Support/Cache/CacheKey.php | 35 ++ docs/REFACTOR_TODO.md | 282 ++++++++++++++ powershell/assignIPLinux.ps1 | 3 +- routes/api.php | 27 +- routes/client.php | 2 + 36 files changed, 1694 insertions(+), 517 deletions(-) delete mode 100644 app/Http/.DS_Store create mode 100644 app/Http/Controllers/Api/Admin/EmailTemplate/EmailTemplateController.php create mode 100644 app/Http/Controllers/Api/Admin/InfraManagement/InfraManagementController.php create mode 100644 app/Http/Services/vCenter/Concerns/ManagesSsh.php create mode 100644 app/Jobs/VirtualMachine/ChangeVmPasswordJob.php create mode 100644 app/Services/StudentService.php create mode 100644 app/Support/Cache/CacheKey.php create mode 100644 docs/REFACTOR_TODO.md diff --git a/app/Http/.DS_Store b/app/Http/.DS_Store deleted file mode 100644 index e339e694ad7e154137de17eab5cfce76fa9cfe49..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHM%}*0S6n{f2ETEH+=%g@CyDWIF!9Dw-+X|xpN5zi)R|`HO?Q58_V<3X%<`rHK*}9^5?~Yn z46Mu+hOtQ~Y@F3t>62G(p(W@K-~$C0KMm~Zq7~2zXa%$aS^=%VWmEv)*{m2T?|m_< zTdjar;J;LW?GGkaW=qocgymZYHarDDo5#z%U?1}U?qf<@lC~!-cf~nH_CTa7ktqfd z?l^8TINFl5Jz<4A5aA9)-kHb@h48zhpUL4sO2X<^E1(sKE5LI1VVK9f-Gg>0e-G;2 zpjCHi9T9KqE!PR!#o{N@m&;%2AHXs^ux@SBuGOx!J3+bTKVsijD0S;+{aSt1X>_ZF zk$LL1YmV3Oxr1Xj5VH2b@od^H(~f5c+}9EVgIKDC(Y?Lt+1umh%*>tradU5Ws)+sB zsr~&y+`gqb|-k0Vqr5PjVkl*2S8}=2E$3;X=G} z8+c0?d|sQ7X5Y)5*iaE55b~^Qr8zks*HYk?}LYLQXzDd5&Or zId;$K;&1MrvA9C2=T6L|*j3H9=+)h?XMax-ja0?6a3Q_(M|jJ)-jV&0VRXq>ASZ@L zS^mF}e*T|5i5}GoXa)Xi1xUVPRmvE`*}u`zXjraoV%@;X3d8k;orderBy('id')->get(); + + return response()->json(['error' => false, 'data' => $templates]); + } + + public function show(int $id): JsonResponse + { + $template = Template::findOrFail($id); + + return response()->json(['error' => false, 'data' => $template]); + } + + public function store(Request $request): JsonResponse + { + $data = $request->validate([ + 'type_id' => 'required|integer|min:1', + 'name' => 'nullable|string|max:100', + 'subject' => 'nullable|string|max:100', + 'message' => 'nullable|string', + 'status' => 'boolean', + ]); + + $template = DB::transaction(fn () => Template::create($data)); + + return response()->json([ + 'error' => false, + 'message' => 'Thêm email template thành công', + 'data' => $template, + ], 201); + } + + public function update(Request $request, int $id): JsonResponse + { + $template = Template::findOrFail($id); + + $data = $request->validate([ + 'type_id' => 'sometimes|integer|min:1', + 'name' => 'sometimes|nullable|string|max:100', + 'subject' => 'sometimes|nullable|string|max:100', + 'message' => 'sometimes|nullable|string', + 'status' => 'sometimes|boolean', + ]); + + DB::transaction(fn () => $template->update($data)); + + return response()->json([ + 'error' => false, + 'message' => 'Cập nhật email template thành công', + 'data' => $template->fresh(), + ]); + } + + public function destroy(int $id): JsonResponse + { + $template = Template::findOrFail($id); + + DB::transaction(fn () => $template->delete()); + + return response()->json([ + 'error' => false, + 'message' => 'Đã xoá email template', + ]); + } +} diff --git a/app/Http/Controllers/Api/Admin/InfraManagement/InfraManagementController.php b/app/Http/Controllers/Api/Admin/InfraManagement/InfraManagementController.php new file mode 100644 index 0000000..53c8654 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/InfraManagement/InfraManagementController.php @@ -0,0 +1,161 @@ +get('per_page', 15), 500); + $filter = $request->get('filter'); + $type = $request->get('type'); + $status = $request->get('status'); + + $query = InfraManagement::query()->select([ + 'id', 'name', 'host', 'username', 'token', 'type', 'status', + 'description', 'meta_data', 'created_at', 'updated_at', + ]); + + if ($filter) { + $query->where(function ($q) use ($filter) { + $q->where('name', 'like', "%{$filter}%") + ->orWhere('host', 'like', "%{$filter}%"); + }); + } + + if ($type !== null && $type !== '') { + if (is_array($type)) { + $query->whereIn('type', $type); + } else { + $query->where('type', $type); + } + } + + if ($status !== null && $status !== '') { + $query->where('status', filter_var($status, FILTER_VALIDATE_BOOLEAN)); + } + + $paginator = $query->latest()->paginate($perPage); + + return response()->json([ + 'error' => false, + 'data' => [ + 'items' => $paginator->items(), + 'total' => $paginator->total(), + 'per_page' => $paginator->perPage(), + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + ], + ]); + } + + public function show(int $id): JsonResponse + { + $infra = InfraManagement::select([ + 'id', 'name', 'host', 'username', 'token', 'type', 'status', + 'description', 'meta_data', 'created_at', 'updated_at', + ])->find($id); + + if (!$infra) { + return response()->json(['error' => true, 'message' => "Không tìm thấy cấu hình #$id"], 404); + } + + return response()->json(['error' => false, 'data' => $infra]); + } + + public function store(Request $request): JsonResponse + { + $request->validate([ + 'name' => 'required|string|max:255', + 'host' => 'required|string|max:255', + 'username' => 'required|string|max:255', + 'password' => 'required|string', + 'token' => 'nullable|string', + 'type' => 'required|string|in:vcenter,esxi,jumphost', + 'status' => 'nullable|boolean', + 'description' => 'nullable|string', + 'meta_data' => 'nullable|array', + ]); + + DB::beginTransaction(); + try { + $data = $request->except(['password']); + $data['password'] = SignRSA::EncryptFunc($request->password); + $data['status'] = $request->boolean('status', true); + + $infra = InfraManagement::create($data); + + // Return without password + $infra->makeHidden(['password']); + + DB::commit(); + + return response()->json(['error' => false, 'data' => $infra], 201); + } catch (\Exception $e) { + DB::rollBack(); + return response()->json(['error' => true, 'message' => $e->getMessage()], 500); + } + } + + public function update(Request $request, int $id): JsonResponse + { + $infra = InfraManagement::find($id); + + if (!$infra) { + return response()->json(['error' => true, 'message' => "Không tìm thấy cấu hình #$id"], 404); + } + + $request->validate([ + 'name' => 'required|string|max:255', + 'host' => 'required|string|max:255', + 'username' => 'required|string|max:255', + 'password' => 'nullable|string', + 'token' => 'nullable|string', + 'type' => 'required|string|in:vcenter,esxi,jumphost', + 'status' => 'nullable|boolean', + 'description' => 'nullable|string', + 'meta_data' => 'nullable|array', + ]); + + DB::beginTransaction(); + try { + $data = $request->except(['password']); + $data['status'] = $request->boolean('status', $infra->status); + + if (!empty($request->password)) { + $data['password'] = SignRSA::EncryptFunc($request->password); + } + + $infra->update($data); + $infra->refresh(); + $infra->makeHidden(['password']); + + DB::commit(); + + return response()->json(['error' => false, 'data' => $infra]); + } catch (\Exception $e) { + DB::rollBack(); + return response()->json(['error' => true, 'message' => $e->getMessage()], 500); + } + } + + public function destroy(int $id): JsonResponse + { + $infra = InfraManagement::find($id); + + if (!$infra) { + return response()->json(['error' => true, 'message' => "Không tìm thấy cấu hình #$id"], 404); + } + + $infra->delete(); + + return response()->json(['error' => false, 'message' => 'Đã xoá cấu hình thành công']); + } +} diff --git a/app/Http/Controllers/Api/Admin/Setting/SettingController.php b/app/Http/Controllers/Api/Admin/Setting/SettingController.php index ee4ece7..a3c6eef 100644 --- a/app/Http/Controllers/Api/Admin/Setting/SettingController.php +++ b/app/Http/Controllers/Api/Admin/Setting/SettingController.php @@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api\Admin\Setting; use App\Http\Controllers\Controller; use App\Http\Facade\SignRSA; use App\Models\Setting; +use App\Support\Cache\CacheKey; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; @@ -90,7 +91,7 @@ class SettingController extends Controller ); // Flush mail config cache so new SMTP settings take effect immediately - Cache::forget('mail_config'); + Cache::forget(CacheKey::build('settings', 'mail')); } return response()->json([ diff --git a/app/Http/Controllers/Api/Admin/VPS/VPSController.php b/app/Http/Controllers/Api/Admin/VPS/VPSController.php index 628ffc7..7fa50e2 100644 --- a/app/Http/Controllers/Api/Admin/VPS/VPSController.php +++ b/app/Http/Controllers/Api/Admin/VPS/VPSController.php @@ -102,7 +102,7 @@ class VPSController extends Controller ], 400); } - $result = $this->vCenterService->restartVM($vps, $vps->vmware_id); + $result = $this->vCenterService->StartVirtualMachine($vps, $vps->vmware_id); if (is_string($result)) { return response()->json([ @@ -114,7 +114,7 @@ class VPSController extends Controller if ($result['error'] ?? true) { return response()->json([ 'error' => true, - 'message' => $result['message'] ?? 'Failed to power on VM' + 'message' => $result['data'] ?? 'Failed to power on VM' ], 400); } @@ -147,7 +147,7 @@ class VPSController extends Controller ], 400); } - $result = $this->vCenterService->restartVM($vps, $vps->vmware_id); + $result = $this->vCenterService->ShutdownGuestVirtualMachine($vps, $vps->vmware_id); if (is_string($result)) { return response()->json([ @@ -159,7 +159,7 @@ class VPSController extends Controller if ($result['error'] ?? true) { return response()->json([ 'error' => true, - 'message' => $result['message'] ?? 'Failed to power off VM' + 'message' => $result['data'] ?? 'Failed to power off VM' ], 400); } diff --git a/app/Http/Controllers/Api/Service/VmController.php b/app/Http/Controllers/Api/Service/VmController.php index b7f7b21..a842578 100644 --- a/app/Http/Controllers/Api/Service/VmController.php +++ b/app/Http/Controllers/Api/Service/VmController.php @@ -10,8 +10,11 @@ use App\Http\Controllers\Api\Service\Concerns\ResolvesVm; use App\Http\Controllers\Controller; use App\Http\Services\Telegram; use App\Http\Services\vCenter; +use App\Jobs\VirtualMachine\ChangeVmPasswordJob; use App\Models\ModuleVmware; +use App\Models\Os; use App\Models\Service; +use App\Services\VmProvisionService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; @@ -304,6 +307,94 @@ class VmController extends Controller ]); } + // ------------------------------------------------------------------------- + // Change Password + // ------------------------------------------------------------------------- + + /** + * POST /v1/system/vm-change-password + * + * Body: { product, current_password, new_password } + * Dispatches ChangeVmPasswordJob — returns immediately, runs async. + */ + public function changePassword(Request $request): JsonResponse + { + $validated = $request->validate([ + 'product' => ['required'], + 'current_password' => ['required', 'string'], + 'new_password' => ['required', 'string', 'min:8'], + ]); + + $vm = $this->resolveVmById($validated['product'], $request); + if ($vm instanceof JsonResponse) { + return $vm; + } + + // Detect OS family from VM's os_id record + $os = $vm->os_id ? Os::with('osGroup')->find($vm->os_id) : null; + $osGroupName = strtolower($os?->osGroup?->name ?? ''); + $osFamily = str_contains($osGroupName, 'windows') + ? VmProvisionService::OS_WINDOWS + : VmProvisionService::OS_LINUX; + + ChangeVmPasswordJob::dispatch( + $vm->id, + $vm->vmware_id, + $osFamily, + $validated['new_password'], + $validated['current_password'], + ); + + return response()->json([ + 'success' => true, + 'message' => 'Yêu cầu đổi mật khẩu đã được ghi nhận. Hệ thống sẽ xử lý trong vài phút.', + ]); + } + + // ------------------------------------------------------------------------- + // Power State + // ------------------------------------------------------------------------- + + /** + * GET /v1/system/vm-power-state?product={id|uuid|vmware_id} + * + * Calls vSphere REST API directly: GET /rest/vcenter/vm/{vmware_id}/power + * Returns: { success: true, data: { state: "POWERED_ON"|"POWERED_OFF"|"SUSPENDED", clean_power_off: bool } } + */ + public function powerState(Request $request, vCenter $serviceVCenter): JsonResponse + { + $productId = $request->input('product'); + if (!$productId) { + return response()->json(['success' => false, 'message' => 'Thiếu product id.'], 422); + } + + $vm = $this->resolveVmById($productId, $request); + if ($vm instanceof JsonResponse) { + return $vm; + } + + $result = $serviceVCenter->getVmPowerState($vm, $vm->vmware_id); + + if ($result === 'Login_Again') { + return response()->json(['success' => false, 'message' => 'Không thể đăng nhập vào vCenter.'], 400); + } + + if (is_array($result) && ($result['error'] ?? true)) { + return response()->json([ + 'success' => false, + 'message' => $result['data'] ?? 'Không thể lấy trạng thái VM.', + ], 400); + } + + return response()->json([ + 'success' => true, + 'data' => [ + 'state' => $result['data']['state'] ?? null, + 'clean_power_off' => $result['data']['clean_power_off'] ?? null, + ], + ]); + } + // ------------------------------------------------------------------------- // Shared helpers // ------------------------------------------------------------------------- diff --git a/app/Http/Controllers/Backend/DomainController.php b/app/Http/Controllers/Backend/DomainController.php index fe4ee50..9941a7d 100644 --- a/app/Http/Controllers/Backend/DomainController.php +++ b/app/Http/Controllers/Backend/DomainController.php @@ -9,6 +9,7 @@ use App\Repositories\DnsManager\Domain\DnsDomainRepositoryInterface; use App\Services\ServerService; use Exception; use Illuminate\Support\Carbon; +use App\Support\Cache\CacheKey; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -356,19 +357,19 @@ class DomainController extends Controller private function clearDomainCaches($domain): void { try { - Cache::forget("domain.{$domain->id}"); - Cache::forget("domain.{$domain->domain_name}"); - Cache::forget('domains.all'); - Cache::forget('domains.filtered'); + // Clear specific domain keys + Cache::forget(CacheKey::build('domain', 'detail', ['id' => $domain->id])); + Cache::forget(CacheKey::build('domain', 'detail', ['name' => $domain->domain_name])); + Cache::forget(CacheKey::build('domain', 'all')); if ($domain->users) { foreach ($domain->users as $user) { - Cache::forget("user.{$user->id}.domains"); + Cache::forget(CacheKey::build('domain', 'user-list', ['user_id' => $user->id])); } } if ($domain->dns_manager_server_id) { - Cache::forget("server.{$domain->dns_manager_server_id}.domains"); + Cache::forget(CacheKey::build('domain', 'server-list', ['server_id' => $domain->dns_manager_server_id])); } } catch (Exception $e) { Log::warning('Failed to clear domain caches: ' . $e->getMessage()); diff --git a/app/Http/Controllers/admin/GroupController.php b/app/Http/Controllers/admin/GroupController.php index 6c7c8a1..7db2d0c 100644 --- a/app/Http/Controllers/admin/GroupController.php +++ b/app/Http/Controllers/admin/GroupController.php @@ -10,6 +10,7 @@ use App\Models\Type; use App\Repositories\Group\GroupRepositoryInterface; use App\Repositories\Log\LogRepositoryInterface; use Illuminate\Http\Request; +use App\Support\Cache\CacheKey; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; @@ -154,23 +155,19 @@ class GroupController extends Controller /** * Clear all group and product-related caches */ - private function clearGroupCaches($typeId) + private function clearGroupCaches($typeId): void { - // Clear general shop caches - Cache::forget('shop_types_with_groups'); - + Cache::forget(CacheKey::build('group', 'shop-types')); + if ($typeId) { - // Clear type-specific group cache - Cache::forget("groups_type_{$typeId}"); - - // Clear all product caches for this type + Cache::forget(CacheKey::build('group', 'by-type', ['type_id' => $typeId])); + $groups = $this->groupRepository->filter(['type_id' => $typeId]); foreach ($groups as $group) { - Cache::forget("products_type_{$typeId}_group_{$group->id}"); + Cache::forget(CacheKey::build('group', 'products', ['type_id' => $typeId, 'group_id' => $group->id])); if ($group->slug) { - // Clear paginated product caches for multiple pages for ($page = 1; $page <= 10; $page++) { - Cache::forget("group_products_{$group->slug}_page_{$page}"); + Cache::forget(CacheKey::build('group', 'slug-products', ['page' => $page, 'slug' => $group->slug])); } } } diff --git a/app/Http/Controllers/admin/VCenterManagerController.php b/app/Http/Controllers/admin/VCenterManagerController.php index c6e5d09..cabe47c 100644 --- a/app/Http/Controllers/admin/VCenterManagerController.php +++ b/app/Http/Controllers/admin/VCenterManagerController.php @@ -560,6 +560,105 @@ class VCenterManagerController extends Controller return response()->json(['error' => true, 'message' => $errorData['value'] ?? $errorData['error_type'] ?? 'Failed to power off VM'], 400); } + public function restart(Request $request) + { + $vmId = (int) $request->input('vm_id'); + + $vm = VirtualMachines::find($vmId); + + if (!$vm) { + return response()->json(['error' => true, 'message' => 'VM not found'], 404); + } + + $vmwareId = $vm->vm_id; + + if (!$vmwareId) { + return response()->json(['error' => true, 'message' => 'VM VMware ID not found'], 400); + } + + $vcenter = $vm->datacenter?->vCenter ?? $vm->host?->datacenter?->vCenter; + + if (!$vcenter) { + return response()->json(['error' => true, 'message' => 'vCenter not found'], 400); + } + + $vCenterService = new \App\Http\Services\vCenter(); + + if (!$vCenterService->loginServer($vcenter)) { + return response()->json(['error' => true, 'message' => 'Cannot login to vCenter'], 400); + } + + $response = Http::withoutVerifying() + ->withHeaders(['vmware-api-session-id' => $vCenterService->token]) + ->post("https://{$vCenterService->apiUri}/rest/vcenter/vm/$vmwareId/power/reset"); + + if ($response->status() === 200) { + return response()->json(['error' => false, 'message' => 'Restart command sent successfully']); + } + + $errorData = $response->json(); + return response()->json([ + 'error' => true, + 'message' => $errorData['value'] ?? $errorData['error_type'] ?? 'Failed to restart VM', + ], 400); + } + + public function snapshot(Request $request) + { + $request->validate([ + 'vm_id' => 'required|integer', + 'name' => 'required|string|max:255', + 'description' => 'nullable|string|max:1000', + ]); + + $vmId = (int) $request->input('vm_id'); + + $vm = VirtualMachines::find($vmId); + + if (!$vm) { + return response()->json(['error' => true, 'message' => 'VM not found'], 404); + } + + $vmwareId = $vm->vm_id; + + if (!$vmwareId) { + return response()->json(['error' => true, 'message' => 'VM VMware ID not found'], 400); + } + + $vcenter = $vm->datacenter?->vCenter ?? $vm->host?->datacenter?->vCenter; + + if (!$vcenter) { + return response()->json(['error' => true, 'message' => 'vCenter not found'], 400); + } + + $vCenterService = new \App\Http\Services\vCenter(); + + if (!$vCenterService->loginServer($vcenter)) { + return response()->json(['error' => true, 'message' => 'Cannot login to vCenter'], 400); + } + + $response = Http::withoutVerifying() + ->withHeaders(['vmware-api-session-id' => $vCenterService->token]) + ->post("https://{$vCenterService->apiUri}/rest/vcenter/vm/$vmwareId/snapshot", [ + 'spec' => [ + 'name' => $request->input('name'), + 'description' => $request->input('description', ''), + 'memory' => false, + 'quiesce' => false, + ], + ]); + + if ($response->status() === 200) { + return response()->json(['error' => false, 'message' => 'Snapshot created successfully']); + } + + $errorData = $response->json(); + return response()->json([ + 'error' => true, + 'message' => $errorData['value'] ?? $errorData['error_type'] ?? 'Failed to create snapshot', + ], 400); + } + public function getPoweredOffVms() { $vms = VirtualMachines::whereNotNull('vm_id') diff --git a/app/Http/Services/vCenter.php b/app/Http/Services/vCenter.php index 2f3001b..0659c22 100644 --- a/app/Http/Services/vCenter.php +++ b/app/Http/Services/vCenter.php @@ -13,9 +13,12 @@ use App\Http\Facade\SignRSA; use App\Models\InfraManagement; use Illuminate\Support\Str; use phpseclib3\Net\SSH2; +use App\Http\Services\vCenter\Concerns\ManagesSsh; class vCenter { + use ManagesSsh; + public ?string $token = null; public string $apiUri; private string $username; @@ -364,224 +367,7 @@ class vCenter return $this->getAllNetwork($host); } - protected function Command($cmd) - { - $auth = $this->LoginAuth(); - - if (!$auth) { - return "Login_Fail"; - } - - try { - // Build PowerShell command inline - $psCommand = 'powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "' . - 'Import-Module VMware.PowerCLI; ' . - 'Set-PowerCLIConfiguration -InvalidCertificateAction Ignore -Confirm:$false -Scope Session; ' . - '$cred = Import-Clixml -Path \'C:\secure\vcenter.cred\'; ' . - 'Connect-VIServer -Server \'' . $this->apiUri . '\' -Credential $cred; ' . - $cmd . '; ' . - 'Disconnect-VIServer -Confirm:$false" 2>&1'; - - Log::channel('Custom')->info('PowerShell Command: ' . substr($psCommand, 0, 200) . '...'); - - $out = $auth->exec($psCommand); - $err = $auth->getStdError(); - - Log::channel('Custom')->info('PowerShell Output length: ' . strlen($out)); - - return $out . ($err ? "\n[STDERR]\n" . $err : ''); - - } catch (\Exception $e) { - Log::channel('Custom')->error('Command Exception: ' . $e->getMessage()); - return $e->getMessage(); - } - } - private function LoginAuth() - { - try { - $infra = InfraManagement::where('type', 'jumphost') - ->where('status', true) - ->first(); - - if (!$infra) { - Log::error('SSH Login Failed: No active jumphost configuration found in InfraManagement.'); - return false; - } - - $sshHost = $infra->host; - $sshPort = 22; - if (isset($infra->meta_data['port'])) { - $sshPort = $infra->meta_data['port']; - } - - $sshUser = $infra->username; - $sshPass = SignRSA::DecryptFunc($infra->password); - - $connection = new SSH2($sshHost, $sshPort); - - if ($connection->login($sshUser, $sshPass)) { - $connection->setTimeout(0); - return $connection; - } - - Log::error('SSH Login Failed: Invalid credentials for jumphost.'); - return false; - } catch (Exception $error) { - Log::error('SSH Login Failed: ' . $error->getMessage()); - return false; - } - } - - // Login SSH - public function loginSSH(): bool - { - $conn = $this->LoginAuth(); - - if ($conn) { - Log::info("✅ Connected to vCenter via SSH successfully"); - return true; - } - - Log::error("❌ Failed to connect to vCenter via SSH"); - return false; - } - - - public function runPsCommand(string $psCommand): string - { - $auth = $this->LoginAuth(); - if (!$auth) - return "Login_Fail"; - - // gọi trực tiếp -Command, thu cả STDERR - $action = 'powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass ' - . '-Command "' . str_replace('"', '\"', $psCommand) . '"'; - - try { - $out = $auth->exec($action); - $errOut = $auth->getStdError(); - return $out . ($errOut ? "\n[STDERR]\n" . $errOut : ''); - } catch (\Throwable $e) { - return "Error: " . $e->getMessage(); - } - } - - - protected function CommandRSP(string $cmd) - { - $auth = $this->LoginAuth(); - if (!$auth) - return "Login_Fail"; - - // Escape để dùng trong chuỗi single-quoted của PowerShell - $esc = fn(string $v) => str_replace("'", "''", $v); - - $server = $esc($this->apiUri ?? ''); - $user = $esc($this->username ?? ''); - $pass = $esc($this->password ?? ''); - - // Ghép script PowerShell hoàn chỉnh, chạy trong 1 scriptblock - // Lưu ý: dùng placeholder rồi strtr để tránh lẫn dấu ngoặc - $psTemplate = <<<'PS' -$ProgressPreference = 'SilentlyContinue' -$InformationPreference = 'SilentlyContinue' -$WarningPreference = 'SilentlyContinue' -$ErrorActionPreference = 'Stop' - -Import-Module VMware.PowerCLI -ErrorAction Stop -Set-PowerCLIConfiguration -InvalidCertificateAction Ignore -Scope Session -Confirm:$false | Out-Null -Connect-VIServer -Server '' -User '' -Password '' | Out-Null - -# Thực thi lệnh công việc (đã được xây dựng sẵn, ví dụ: & 'C:\path\script.ps1' -Param 'Value' ... ) -& { - -} | Out-String -PS; - - // Chèn dữ liệu - $psScript = strtr($psTemplate, [ - '' => $server, - '' => $user, - '' => $pass, - '' => $cmd, // ở đây $cmd KHÔNG cần ngoặc kép bao ngoài - ]); - - // Encode UTF-16LE → Base64 cho -EncodedCommand - $encoded = base64_encode(mb_convert_encoding($psScript, 'UTF-16LE', 'UTF-8')); - - // Dùng -EncodedCommand để tránh mọi lỗi escape/quote - $action = "powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand $encoded"; - - try { - $out = $auth->exec($action); - $errOut = $auth->getStdError(); - return $out . ($errOut ? "\n[STDERR]\n" . $errOut : ''); - } catch (\Throwable $e) { - return "Error: " . $e->getMessage(); - } - } - - protected function CommandChangePort(string $cmd, bool $expectJson = false) - { - $auth = $this->LoginAuth(); - if (!$auth) - return "Login_Fail"; - - $esc = fn(string $v) => str_replace("'", "''", $v); - - $server = $esc($this->apiUri ?? ''); - $user = $esc($this->username ?? ''); - $pass = $esc($this->password ?? ''); - - // PowerShell script template - $psTemplate = <<<'PS' -$ProgressPreference = 'SilentlyContinue' -$InformationPreference = 'SilentlyContinue' -$WarningPreference = 'SilentlyContinue' -$ErrorActionPreference = 'Stop' -[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 - -Import-Module VMware.PowerCLI -ErrorAction Stop -Set-PowerCLIConfiguration -InvalidCertificateAction Ignore -Scope Session -Confirm:$false | Out-Null -Connect-VIServer -Server '' -User '' -Password '' | Out-Null - -& { - -} -PS; - - $psScript = strtr($psTemplate, [ - '' => $server, - '' => $user, - '' => $pass, - '' => $cmd, - ]); - - // Encode UTF-16LE cho -EncodedCommand - $encoded = base64_encode(mb_convert_encoding($psScript, 'UTF-16LE', 'UTF-8')); - $action = "powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand $encoded"; - - try { - $out = $auth->exec($action); - $errOut = $auth->getStdError(); - - if ($expectJson) { - // CẮT JSON cuối: {...} hoặc [...] - $trimmed = trim($out); - // đơn giản: bắt khối { ... } hoặc [ ... ] ở cuối chuỗi - if (preg_match('~(\{.*\}|\[.*\])\s*$~s', $trimmed, $m)) { - return $m[1]; - } - // không tìm thấy → trả nguyên STDOUT (để caller log raw) - return $trimmed; - } - - // Trường hợp không kỳ vọng JSON: giữ nguyên hành vi cũ (append STDERR) - return $out . ($errOut ? "\n[STDERR]\n" . $errOut : ''); - } catch (\Throwable $e) { - return "Error: " . $e->getMessage(); - } - } + // Moved to App\Http\Services\vCenter\Concerns\ManagesSsh public function resetGuestPasswordViaPs1(string $vmName, string $guestUser, string $guestPassword, string $newPassword, string $targetUser = 'Administrator'): array|string { @@ -1769,51 +1555,72 @@ PS; public function StartVirtualMachine($product, $vmware_id = ''): array|string { - - $check = $this->loginServer($product->ipaddress->host->datacenter->vCenter); - if ($check) { - $response = Http::withoutVerifying() - ->withHeaders([ - 'vmware-api-session-id' => $this->token - ]) - ->post("https://$this->apiUri/rest/vcenter/vm/$vmware_id/power/start"); - if ($response->status() === 200) { - return [ - 'error' => false, - 'data' => 'success' - ]; + try { + $vmId = $vmware_id ?: ($product->vmware_id ?? null); + if (!$vmId) { + return ['error' => true, 'data' => 'Không tìm thấy vmware_id']; } - return [ - 'error' => true, - 'data' => $response['value'] - ]; + + $vcenter = $product->vCenter ?? null; + if (!$vcenter && $product->ipAddress?->host?->datacenter?->vCenter) { + $vcenter = $product->ipAddress->host->datacenter->vCenter; + } + + if (!$vcenter) { + return ['error' => true, 'data' => 'Không tìm thấy thông tin vCenter cho VM']; + } + + if (!$this->loginServer($vcenter)) { + return 'Login_Again'; + } + + $response = Http::withoutVerifying() + ->withHeaders(['vmware-api-session-id' => $this->token]) + ->post("https://$this->apiUri/rest/vcenter/vm/$vmId/power/start"); + + if ($response->status() === 200) { + return ['error' => false, 'data' => 'success']; + } + + return ['error' => true, 'data' => $response->json('value') ?? $response->body()]; + } catch (\Throwable $e) { + return ['error' => true, 'data' => $e->getMessage()]; } - return "Login_Again"; } public function ShutdownGuestVirtualMachine($product, $vmware_id = ''): array|string { - $check = $this->loginServer($product->ipaddress->host->datacenter->vCenter); - - if ($check) { - $response = Http::withoutVerifying() - ->withHeaders([ - 'vmware-api-session-id' => $this->token - ]) - ->post("https://$this->apiUri/rest/vcenter/vm/$vmware_id/guest/power?action=shutdown"); - if ($response->status() === 200) { - return [ - 'error' => false, - 'data' => 'success' - ]; + try { + $vmId = $vmware_id ?: ($product->vmware_id ?? null); + if (!$vmId) { + return ['error' => true, 'data' => 'Không tìm thấy vmware_id']; } - return [ - 'error' => true, - 'data' => $response['value'] - ]; + $vcenter = $product->vCenter ?? null; + if (!$vcenter && $product->ipAddress?->host?->datacenter?->vCenter) { + $vcenter = $product->ipAddress->host->datacenter->vCenter; + } + + if (!$vcenter) { + return ['error' => true, 'data' => 'Không tìm thấy thông tin vCenter cho VM']; + } + + if (!$this->loginServer($vcenter)) { + return 'Login_Again'; + } + + $response = Http::withoutVerifying() + ->withHeaders(['vmware-api-session-id' => $this->token]) + ->post("https://$this->apiUri/rest/vcenter/vm/$vmId/guest/power?action=shutdown"); + + if ($response->status() === 200) { + return ['error' => false, 'data' => 'success']; + } + + return ['error' => true, 'data' => $response->json('value') ?? $response->body()]; + } catch (\Throwable $e) { + return ['error' => true, 'data' => $e->getMessage()]; } - return "Login_Again"; } public function PowerOffVirtualMachine($vmware_id = ''): array|string @@ -1839,6 +1646,43 @@ PS; return "Login_Again"; } + public function getVmPowerState($product, string $vmware_id = ''): array|string + { + try { + $vmId = $vmware_id ?: ($product->vmware_id ?? null); + if (!$vmId) { + return ['error' => true, 'data' => 'Không tìm thấy vmware_id']; + } + + $vcenter = $product->vCenter ?? null; + if (!$vcenter && $product->ipAddress?->host?->datacenter?->vCenter) { + $vcenter = $product->ipAddress->host->datacenter->vCenter; + } + + if (!$vcenter) { + return ['error' => true, 'data' => 'Không tìm thấy thông tin vCenter cho VM']; + } + + if (!$this->loginServer($vcenter)) { + return 'Login_Again'; + } + + $response = Http::withoutVerifying() + ->withHeaders(['vmware-api-session-id' => $this->token]) + ->timeout(8) + ->get("https://$this->apiUri/rest/vcenter/vm/$vmId/power"); + + if ($response->successful()) { + $data = $response->json(); + return ['error' => false, 'data' => $data['value'] ?? $data]; + } + + return ['error' => true, 'data' => $response->json('value') ?? 'Không thể lấy trạng thái VM']; + } catch (\Throwable $e) { + return ['error' => true, 'data' => $e->getMessage()]; + } + } + public function DeleteVirtualMachine($vmware_id = ''): array|string { $check = $this->checkApiExits(); diff --git a/app/Http/Services/vCenter/Concerns/ManagesSsh.php b/app/Http/Services/vCenter/Concerns/ManagesSsh.php new file mode 100644 index 0000000..6d7bc01 --- /dev/null +++ b/app/Http/Services/vCenter/Concerns/ManagesSsh.php @@ -0,0 +1,231 @@ +where('status', true) + ->first(); + + if (!$infra) { + Log::error('SSH Login Failed: No active jumphost configuration found in InfraManagement.'); + return false; + } + + $sshHost = $infra->host; + $sshPort = 22; + if (isset($infra->meta_data['port'])) { + $sshPort = (int) $infra->meta_data['port']; + } + + $connection = new SSH2($sshHost, $sshPort); + + if ($connection->login($infra->username, SignRSA::DecryptFunc($infra->password))) { + $connection->setTimeout(0); + return $connection; + } + + Log::error('SSH Login Failed: Invalid credentials for jumphost.'); + return false; + } catch (Exception $error) { + Log::error('SSH Login Failed: ' . $error->getMessage()); + return false; + } + } + + public function loginSSH(): bool + { + $conn = $this->LoginAuth(); + + if ($conn) { + Log::info('✅ Connected to vCenter via SSH successfully'); + return true; + } + + Log::error('❌ Failed to connect to vCenter via SSH'); + return false; + } + + // ─── Command variants ──────────────────────────────────────────────────── + + /** + * Run a PowerShell command using a stored credential file on the jumphost. + * Uses C:\secure\vcenter.cred for vCenter auth — no plain-text password in command. + */ + protected function Command($cmd): string + { + $auth = $this->LoginAuth(); + if (!$auth) { + return 'Login_Fail'; + } + + try { + $psCommand = 'powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "' + . 'Import-Module VMware.PowerCLI; ' + . 'Set-PowerCLIConfiguration -InvalidCertificateAction Ignore -Confirm:$false -Scope Session; ' + . '$cred = Import-Clixml -Path \'C:\secure\vcenter.cred\'; ' + . 'Connect-VIServer -Server \'' . $this->apiUri . '\' -Credential $cred; ' + . $cmd . '; ' + . 'Disconnect-VIServer -Confirm:$false" 2>&1'; + + Log::channel('Custom')->info('PowerShell Command: ' . substr($psCommand, 0, 200) . '...'); + + $out = $auth->exec($psCommand); + $errOut = $auth->getStdError(); + + Log::channel('Custom')->info('PowerShell Output length: ' . strlen($out)); + + return $out . ($errOut ? "\n[STDERR]\n" . $errOut : ''); + } catch (\Exception $e) { + Log::channel('Custom')->error('Command Exception: ' . $e->getMessage()); + return $e->getMessage(); + } + } + + /** + * Run a raw PowerShell command directly — no vCenter connect wrapper. + */ + public function runPsCommand(string $psCommand): string + { + $auth = $this->LoginAuth(); + if (!$auth) { + return 'Login_Fail'; + } + + $action = 'powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass ' + . '-Command "' . str_replace('"', '\"', $psCommand) . '"'; + + try { + $out = $auth->exec($action); + $errOut = $auth->getStdError(); + return $out . ($errOut ? "\n[STDERR]\n" . $errOut : ''); + } catch (\Throwable $e) { + return 'Error: ' . $e->getMessage(); + } + } + + /** + * Run a command using EncodedCommand — connects to vCenter with plain credentials. + * Safer for complex scripts that would break with inline quoting. + */ + protected function CommandRSP(string $cmd): string + { + $auth = $this->LoginAuth(); + if (!$auth) { + return 'Login_Fail'; + } + + $esc = fn(string $v) => str_replace("'", "''", $v); + $script = strtr( + <<<'PS' +$ProgressPreference = 'SilentlyContinue' +$InformationPreference = 'SilentlyContinue' +$WarningPreference = 'SilentlyContinue' +$ErrorActionPreference = 'Stop' + +Import-Module VMware.PowerCLI -ErrorAction Stop +Set-PowerCLIConfiguration -InvalidCertificateAction Ignore -Scope Session -Confirm:$false | Out-Null +Connect-VIServer -Server '' -User '' -Password '' | Out-Null + +& { + +} | Out-String +PS, + [ + '' => $esc($this->apiUri ?? ''), + '' => $esc($this->username ?? ''), + '' => $esc($this->password ?? ''), + '' => $cmd, + ] + ); + + $encoded = base64_encode(mb_convert_encoding($script, 'UTF-16LE', 'UTF-8')); + $action = "powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand $encoded"; + + try { + $out = $auth->exec($action); + $errOut = $auth->getStdError(); + return $out . ($errOut ? "\n[STDERR]\n" . $errOut : ''); + } catch (\Throwable $e) { + return 'Error: ' . $e->getMessage(); + } + } + + /** + * Run a command using EncodedCommand — variant used for RDP port changes. + * Optionally strips raw output down to the first JSON block found. + */ + protected function CommandChangePort(string $cmd, bool $expectJson = false): string + { + $auth = $this->LoginAuth(); + if (!$auth) { + return 'Login_Fail'; + } + + $esc = fn(string $v) => str_replace("'", "''", $v); + $script = strtr( + <<<'PS' +$ProgressPreference = 'SilentlyContinue' +$InformationPreference = 'SilentlyContinue' +$WarningPreference = 'SilentlyContinue' +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +Import-Module VMware.PowerCLI -ErrorAction Stop +Set-PowerCLIConfiguration -InvalidCertificateAction Ignore -Scope Session -Confirm:$false | Out-Null +Connect-VIServer -Server '' -User '' -Password '' | Out-Null + +& { + +} +PS, + [ + '' => $esc($this->apiUri ?? ''), + '' => $esc($this->username ?? ''), + '' => $esc($this->password ?? ''), + '' => $cmd, + ] + ); + + $encoded = base64_encode(mb_convert_encoding($script, 'UTF-16LE', 'UTF-8')); + $action = "powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand $encoded"; + + try { + $out = $auth->exec($action); + $errOut = $auth->getStdError(); + + if ($expectJson) { + $trimmed = trim($out); + if (preg_match('~(\{.*\}|\[.*\])\s*$~s', $trimmed, $m)) { + return $m[1]; + } + return $trimmed; + } + + return $out . ($errOut ? "\n[STDERR]\n" . $errOut : ''); + } catch (\Throwable $e) { + return 'Error: ' . $e->getMessage(); + } + } +} diff --git a/app/Http/Services/vCenter/SnapshotService.php b/app/Http/Services/vCenter/SnapshotService.php index b00ad75..1fc0fd3 100644 --- a/app/Http/Services/vCenter/SnapshotService.php +++ b/app/Http/Services/vCenter/SnapshotService.php @@ -2,8 +2,9 @@ namespace App\Http\Services\vCenter; -use Illuminate\Support\Facades\Log; +use App\Support\Cache\CacheKey; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Log; use App\Http\Facade\SignRSA; class SnapshotService @@ -68,10 +69,10 @@ class SnapshotService } catch (\Exception $e) { return ['error' => true, 'message' => 'Lỗi setup credentials: ' . $e->getMessage()]; } - + $vmware_name = (string) $product->name; // In original code, it checks checkApiExits(), if true proceed. - + if ($this->client->checkApiExits()) { try { $Snapshot = $vmware_name . "_" . date('d_m_Y_H_i_s'); @@ -102,24 +103,23 @@ class SnapshotService private function clearSnapshotCache($product): void { - $vmware_name = (string) $product->name; - $cacheKey = "snapshots_{$product->id}_{$vmware_name}"; + $cacheKey = CacheKey::build('snapshot', 'list', ['vm_id' => $product->id]); Cache::forget($cacheKey); - Log::channel('Custom')->info("🗑️ Cleared snapshot cache for VM: {$vmware_name}"); + Log::channel('Custom')->info("🗑️ Cleared snapshot cache for VM: {$product->name}"); } public function getSnapShot($product): string|array { // ... (Similar logic, using SOAP) - // Since SOAP logic is quite complex and self-contained in vCenter.php, + // Since SOAP logic is quite complex and self-contained in vCenter.php, // I'll copy it here but need to adapt $this->callSoap etc. // Actually, callSoap uses curl with manual implementation. // I should probably simplify or copy that helper too. - + // This method heavily relies on internal SOAP helpers. - // For simplicity in this 'splitting' task, I will implement the public method + // For simplicity in this 'splitting' task, I will implement the public method // and add the private helpers it depends on. - + try { $vcenterModel = $this->resolveVCenterFromProduct($product); @@ -132,10 +132,8 @@ class SnapshotService return ['error' => true, 'message' => 'Lỗi setup credentials: ' . $e->getMessage()]; } - $vmware_name = (string) $product->name; - $vmId = $product->vmware_id; - - $cacheKey = "snapshots_{$product->id}_{$vmware_name}"; + $vmId = $product->vmware_id; + $cacheKey = CacheKey::build('snapshot', 'list', ['vm_id' => $product->id]); $cachedResult = Cache::get($cacheKey); if ($cachedResult !== null) { @@ -234,7 +232,7 @@ class SnapshotService try { $xml = simplexml_load_string($result['body']); if ($xml === false) return []; - + $xml->registerXPathNamespace('u', 'urn:vim25'); $json = json_encode($xml); $array = json_decode($json, true); @@ -271,14 +269,14 @@ class SnapshotService if (isset($node['childSnapshotList'])) { $children = $node['childSnapshotList']; - + // Check if 'snapshot' key exists directly (single child wrapped) if (isset($children['snapshot'])) { // If it's an array of snapshots or single object? // XML conversion: if strictly one child, might be direct. if multiple, array. // But simplexml usually produces array if multiple same tags. // However, structure is usually childSnapshotList -> snapshot (array or single) - + $snaps = $children['snapshot']; // If $snaps has numeric keys, it's array. Else single. if (array_keys($snaps) !== range(0, count($snaps) - 1)) { @@ -288,7 +286,7 @@ class SnapshotService foreach ($snaps as $child) { $list = array_merge($list, $this->flattenSnapshotTree($child)); } - } + } } return $list; } @@ -296,7 +294,7 @@ class SnapshotService private function callSoap($soapBody, $cookie = null) { $curl = curl_init(); - $host = $this->client->apiUri; + $host = $this->client->apiUri; $headers = [ 'Content-Type: text/xml', @@ -327,11 +325,11 @@ class SnapshotService )); $response = curl_exec($curl); - + $header_size = curl_getinfo($curl, CURLINFO_HEADER_SIZE); $header = substr($response, 0, $header_size); $body = substr($response, $header_size); - + curl_close($curl); // Parse cookie @@ -344,7 +342,7 @@ class SnapshotService } } } - + return ['body' => $body, 'cookie' => $cookieValue]; } } diff --git a/app/Jobs/VirtualMachine/ChangeVmPasswordJob.php b/app/Jobs/VirtualMachine/ChangeVmPasswordJob.php new file mode 100644 index 0000000..0001f2f --- /dev/null +++ b/app/Jobs/VirtualMachine/ChangeVmPasswordJob.php @@ -0,0 +1,99 @@ + $this->moduleVmwareId, + 'vm_id' => $this->vmId, + 'os_family' => $this->osFamily, + ]); + + $vcenter = vCenter::first(); + if (!$vcenter) { + throw new \RuntimeException('No vCenter record found in module_vcenter'); + } + + $output = $ssh->changePasswordCustomer( + vmId: $this->vmId, + newPassword: $this->newPassword, + currentGuestPassword: $this->currentGuestPassword, + osFamily: $this->osFamily, + vcenterHost: $vcenter->host, + vcenterUser: $vcenter->user, + vcenterPassword: SignRSA::DecryptFunc($vcenter->password), + ); + + $this->assertSuccess($output); + + // Persist encrypted password to DB + $provision->updatePassword($this->moduleVmwareId, $this->newPassword); + + Log::info('ChangeVmPasswordJob: password changed successfully', [ + 'module_vmware_id' => $this->moduleVmwareId, + 'vm_id' => $this->vmId, + ]); + } + + public function failed(\Throwable $e): void + { + Log::error('ChangeVmPasswordJob: all retries exhausted', [ + 'module_vmware_id' => $this->moduleVmwareId, + 'vm_id' => $this->vmId, + 'error' => $e->getMessage(), + ]); + } + + private function assertSuccess(string $output): void + { + if (str_contains(strtoupper(trim($output)), 'SUCCESS')) { + return; + } + + throw new \RuntimeException( + "Password change script failed for VM {$this->vmId}. Output: " . trim($output) + ); + } +} diff --git a/app/Jobs/VirtualMachine/Handle/WaitStatusVirtualMachine.php b/app/Jobs/VirtualMachine/Handle/WaitStatusVirtualMachine.php index ec86928..89424ad 100644 --- a/app/Jobs/VirtualMachine/Handle/WaitStatusVirtualMachine.php +++ b/app/Jobs/VirtualMachine/Handle/WaitStatusVirtualMachine.php @@ -75,7 +75,7 @@ class WaitStatusVirtualMachine implements ShouldQueue // Action Completed // 1. Clear Cache again (to ensure next fetch gets new state) - \Illuminate\Support\Facades\Cache::forget('vm_info_' . $this->vmware_id); + \Illuminate\Support\Facades\Cache::forget(\App\Support\Cache\CacheKey::build('vm', 'info', ['id' => $this->vmware_id])); // 2. Update DB Status and Dispatch Event $product = \App\Models\ModuleVmware::where('vmware_id', $this->vmware_id)->first(); diff --git a/app/Jobs/VirtualMachine/Power/RebootVmJob.php b/app/Jobs/VirtualMachine/Power/RebootVmJob.php index 3174224..0b43a29 100644 --- a/app/Jobs/VirtualMachine/Power/RebootVmJob.php +++ b/app/Jobs/VirtualMachine/Power/RebootVmJob.php @@ -57,7 +57,7 @@ class RebootVmJob implements ShouldQueue Log::info("RebootVmJob: Command sent successfully for VM {$product->vmware_id}"); // Clear VM info cache - \Illuminate\Support\Facades\Cache::forget('vm_info_' . $product->vmware_id); + \Illuminate\Support\Facades\Cache::forget(\App\Support\Cache\CacheKey::build('vm', 'info', ['id' => $product->vmware_id])); // Dispatch Event to Frontend \App\Events\VirtualMachineStatusChanged::dispatch( diff --git a/app/Jobs/VirtualMachine/Power/StartVmJob.php b/app/Jobs/VirtualMachine/Power/StartVmJob.php index 1764319..0f90aa7 100644 --- a/app/Jobs/VirtualMachine/Power/StartVmJob.php +++ b/app/Jobs/VirtualMachine/Power/StartVmJob.php @@ -59,7 +59,7 @@ class StartVmJob implements ShouldQueue Log::info("StartVmJob: Command sent successfully for VM {$product->vmware_id}"); // Clear VM info cache - \Illuminate\Support\Facades\Cache::forget('vm_info_' . $product->vmware_id); + \Illuminate\Support\Facades\Cache::forget(\App\Support\Cache\CacheKey::build('vm', 'info', ['id' => $product->vmware_id])); // Dispatch Event to Frontend \App\Events\VirtualMachineStatusChanged::dispatch( diff --git a/app/Jobs/VirtualMachine/Power/StopVmJob.php b/app/Jobs/VirtualMachine/Power/StopVmJob.php index 312e496..37c670c 100644 --- a/app/Jobs/VirtualMachine/Power/StopVmJob.php +++ b/app/Jobs/VirtualMachine/Power/StopVmJob.php @@ -57,7 +57,7 @@ class StopVmJob implements ShouldQueue if (is_array($result) && isset($result['error']) && $result['error'] === false) { // Clear VM info cache - \Illuminate\Support\Facades\Cache::forget('vm_info_' . $product->vmware_id); + \Illuminate\Support\Facades\Cache::forget(\App\Support\Cache\CacheKey::build('vm', 'info', ['id' => $product->vmware_id])); // Dispatch Event to Frontend \App\Events\VirtualMachineStatusChanged::dispatch( diff --git a/app/Jobs/VirtualMachine/Sync/UpdateVMInfoJob.php b/app/Jobs/VirtualMachine/Sync/UpdateVMInfoJob.php index 66d225b..f306e7b 100644 --- a/app/Jobs/VirtualMachine/Sync/UpdateVMInfoJob.php +++ b/app/Jobs/VirtualMachine/Sync/UpdateVMInfoJob.php @@ -126,7 +126,7 @@ class UpdateVMInfoJob implements ShouldQueue ]; // Cache the result for 5 minutes - $cacheKey = "vm_info_{$this->vmId}"; + $cacheKey = \App\Support\Cache\CacheKey::build('vm', 'info', ['id' => $this->vmId]); Cache::put($cacheKey, $vmInfo, 300); Log::info('VM info updated successfully', ['vm_id' => $this->vmId]); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 1f5b1cd..8815cb9 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,13 +4,14 @@ namespace App\Providers; use App\Http\Helpers\Helper; use App\Http\Services\SignRSA; +use App\Services\Cache\CacheService; +use App\Services\Cache\CacheServiceInterface; use App\Models\ModuleVmware; use App\Models\ModuleIpAddress; use App\Observers\ModuleVmwareObserver; use App\Observers\ModuleIpAddressObserver; use Illuminate\Pagination\Paginator; use Illuminate\Support\Facades\Blade; -use Illuminate\Support\Facades\Cache; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -22,6 +23,8 @@ class AppServiceProvider extends ServiceProvider */ public function register() { + $this->app->singleton(CacheServiceInterface::class, CacheService::class); + $this->app->bind('signrsa', function () { return new SignRSA(); }); diff --git a/app/Providers/MailConfigServiceProvider.php b/app/Providers/MailConfigServiceProvider.php index 24fa84f..7047c86 100644 --- a/app/Providers/MailConfigServiceProvider.php +++ b/app/Providers/MailConfigServiceProvider.php @@ -4,6 +4,7 @@ namespace App\Providers; use App\Http\Facade\SignRSA; use App\Models\Setting; +use App\Support\Cache\CacheKey; use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Config; @@ -23,7 +24,7 @@ class MailConfigServiceProvider extends ServiceProvider return; } - $config = Cache::remember('mail_config', 3600, function () { + $config = Cache::remember(CacheKey::build('settings', 'mail'), 3600, function () { if (!Schema::hasTable('setting')) { return null; } diff --git a/app/Repositories/Addon/AddonCachedRepository.php b/app/Repositories/Addon/AddonCachedRepository.php index e44b67a..798bda3 100644 --- a/app/Repositories/Addon/AddonCachedRepository.php +++ b/app/Repositories/Addon/AddonCachedRepository.php @@ -3,7 +3,6 @@ namespace App\Repositories\Addon; use App\Repositories\BaseCachedRepository; -use Illuminate\Support\Facades\Cache; class AddonCachedRepository extends BaseCachedRepository implements AddonRepositoryInterface { @@ -12,10 +11,12 @@ class AddonCachedRepository extends BaseCachedRepository implements AddonReposit ) { parent::__construct(); } + public function getModel() { return $this->addonRepository->getModel(); } + public function filter( array $data = [], array $relationships = [], @@ -23,24 +24,13 @@ class AddonCachedRepository extends BaseCachedRepository implements AddonReposit array $select = ['*'], bool $returnQuery = false ) { - if ( - $returnQuery || - !empty($data['paginate']) - ) { - return $this->addonRepository - ->filter($data, $relationships, $withCount, $select, $returnQuery); + if ($returnQuery || !empty($data['paginate'])) { + return $this->addonRepository->filter($data, $relationships, $withCount, $select, $returnQuery); } - $cacheKey = $this->getCacheKey(md5(json_encode([ - 'filter' => compact('data', 'relationships', 'withCount', 'select') - ]))); - - return Cache::remember( - $cacheKey, - $this->cacheTime, - fn() => - $this->addonRepository - ->filter($data, $relationships, $withCount, $select, $returnQuery) + return $this->rememberFilter( + compact('data', 'relationships', 'withCount', 'select'), + fn () => $this->addonRepository->filter($data, $relationships, $withCount, $select, $returnQuery) ); } } diff --git a/app/Repositories/BaseCachedRepository.php b/app/Repositories/BaseCachedRepository.php index 8c32f12..2a10c01 100644 --- a/app/Repositories/BaseCachedRepository.php +++ b/app/Repositories/BaseCachedRepository.php @@ -2,90 +2,102 @@ namespace App\Repositories; -use Illuminate\Support\Facades\Cache; +use App\Services\Cache\CacheServiceInterface; +use App\Support\Cache\CacheKey; abstract class BaseCachedRepository extends BaseRepository { - protected $cacheTime = 172800; // Thời gian lưu cache )giây) + protected int $cacheTime = 172800; // 48 hours (fallback for raw Cache calls) + + protected CacheServiceInterface $cache; public function __construct() { parent::__construct(); + $this->cache = app(CacheServiceInterface::class); } public function find($id) { - $cacheKey = $this->getCacheKey($id); - - return Cache::remember($cacheKey, $this->cacheTime, function () use ($id) { - return parent::find($id); - }); + $key = CacheKey::build($this->getModuleName(), 'find', ['id' => $id]); + + return $this->cache->remember($key, fn () => parent::find($id), CacheServiceInterface::TTL_LONG); } public function getAll() { - - $cacheKey = $this->getCacheKey("getAll"); - return Cache::remember($cacheKey, $this->cacheTime, function () { - return parent::getAll(); - }); + $key = CacheKey::build($this->getModuleName(), 'all'); + + return $this->cache->remember($key, fn () => parent::getAll(), CacheServiceInterface::TTL_LONG); + } + + public function getCount() + { + $key = CacheKey::build($this->getModuleName(), 'count'); + + return $this->cache->remember($key, fn () => parent::getCount(), CacheServiceInterface::TTL_LONG); } public function create($data = []) { $result = parent::create($data); - $this->clearCache(); + $this->cache->clearByModule($this->getModuleName()); return $result; } + public function insert(array $data = []) { $result = parent::insert($data); - $this->clearCache(); + $this->cache->clearByModule($this->getModuleName()); return $result; } + public function update(int $id = 0, array $data = []) { $result = parent::update($id, $data); - $this->clearCache(); - Cache::forget($this->getCacheKey($id)); + $this->cache->clearByModule($this->getModuleName()); return $result; } public function delete($id) { $result = parent::delete($id); - $this->clearCache(); - Cache::forget($this->getCacheKey($id)); + $this->cache->clearByModule($this->getModuleName()); return $result; } - protected function getCacheKey($method) + public function clearCache(): void { - return sprintf('%s_%s', static::class, $method); + $this->cache->clearByModule($this->getModuleName()); } - public function getCount() - { - $cacheKey = $this->getCacheKey("getCount"); - return Cache::remember($cacheKey, $this->cacheTime, function () { - return parent::getCount(); - }); - } - public function clearCache() - { - // Chỉ xóa cache của repository này, không flush toàn bộ cache app - $keysToForget = [ - $this->getCacheKey('getAll'), - $this->getCacheKey('getCount'), - ]; - foreach ($keysToForget as $key) { - Cache::forget($key); - } - } - public function clearCacheByKey(string $key) + public function clearCacheByKey(string $key): void { - $cacheKey = $this->getCacheKey($key); + $this->cache->forget(CacheKey::build($this->getModuleName(), $key)); + } - Cache::forget($cacheKey); + // ------------------------------------------------------------------------- + // Helpers for child repositories + // ------------------------------------------------------------------------- + + protected function rememberFilter(array $params, callable $query): mixed + { + $key = CacheKey::build($this->getModuleName(), 'filter', [ + 'hash' => md5(serialize($params)), + ]); + + return $this->cache->remember($key, $query, CacheServiceInterface::TTL_LONG); + } + + protected function getModuleName(): string + { + $shortName = class_basename(static::class); + return strtolower(str_replace('CachedRepository', '', $shortName)); + } + + // Keep for backward compat — child classes that still call getCacheKey() directly. + protected function getCacheKey(string $suffix): string + { + return CacheKey::build($this->getModuleName(), $suffix); } } diff --git a/app/Repositories/Customer/CustomerCachedRepository.php b/app/Repositories/Customer/CustomerCachedRepository.php index 936301e..1639d33 100644 --- a/app/Repositories/Customer/CustomerCachedRepository.php +++ b/app/Repositories/Customer/CustomerCachedRepository.php @@ -3,7 +3,7 @@ namespace App\Repositories\Customer; use App\Repositories\BaseCachedRepository; -use Illuminate\Support\Facades\Cache; +use App\Support\Cache\CacheKey; class CustomerCachedRepository extends BaseCachedRepository implements CustomerRepositoryInterface { @@ -31,39 +31,22 @@ class CustomerCachedRepository extends BaseCachedRepository implements CustomerR ->filter($data, $relationships, $withCount, $select, $returnQuery); } - $cacheKey = $this->getCacheKey(md5(json_encode([ - 'filter' => compact('data', 'relationships', 'withCount', 'select') - ]))); - - return Cache::remember( - $cacheKey, - $this->cacheTime, - fn() => - $this->customerRepository - ->filter($data, $relationships, $withCount, $select, $returnQuery) + return $this->rememberFilter( + compact('data', 'relationships', 'withCount', 'select'), + fn() => $this->customerRepository->filter($data, $relationships, $withCount, $select, $returnQuery) ); } public function findWithRelationships(int $id = 0, array $relationships = [], array $withCount = [], array $withSum = []) { - $cacheKey = $this->getCacheKey(md5(json_encode([ - 'findWithRelationships' => [ - 'id' => $id, - 'relationships' => $relationships - ] - ]))); - return Cache::remember($cacheKey, $this->cacheTime, function () use ($id, $relationships, $withCount, $withSum) { + $cacheKey = CacheKey::build($this->getModuleName(), 'find-with-relationships', ['id' => $id]); + return $this->cache->remember($cacheKey, function () use ($id, $relationships, $withCount, $withSum) { return $this->customerRepository->findWithRelationships($id, $relationships, $withCount, $withSum); }); } public function findBy(string $key = '', string $value = '') { - $cacheKey = $this->getCacheKey(md5(json_encode([ - 'findBy' => [ - 'key' => $key, - 'value' => $value - ] - ]))); - return Cache::remember($cacheKey, $this->cacheTime, function () use ($key, $value) { + $cacheKey = CacheKey::build($this->getModuleName(), 'find-by', ['key' => $key, 'value' => $value]); + return $this->cache->remember($cacheKey, function () use ($key, $value) { return $this->customerRepository->findBy($key, $value); }); } diff --git a/app/Repositories/Discount/DiscountCachedRepository.php b/app/Repositories/Discount/DiscountCachedRepository.php index c6947a0..4754629 100644 --- a/app/Repositories/Discount/DiscountCachedRepository.php +++ b/app/Repositories/Discount/DiscountCachedRepository.php @@ -3,7 +3,7 @@ namespace App\Repositories\Discount; use App\Repositories\BaseCachedRepository; -use Illuminate\Support\Facades\Cache; +use App\Support\Cache\CacheKey; class DiscountCachedRepository extends BaseCachedRepository implements DiscountRepositoryInterface { @@ -18,32 +18,19 @@ class DiscountCachedRepository extends BaseCachedRepository implements DiscountR } public function filter(array $data = [], array $relationships = [], array $withCount = [], array $select = ['*']) { - $cacheKey = $this->getCacheKey(md5(json_encode([ - 'filterItem' => [ - 'data' => $data, - 'relationships' => $relationships, - 'select' => $select, - 'withCount' => $withCount - ] - ]))); - return Cache::remember($cacheKey, $this->cacheTime, function () use ($data, $relationships, $select, $withCount) { - return $this->discountRepository->filter($data, $relationships, $withCount, $select); - }); + return $this->rememberFilter( + compact('data', 'relationships', 'withCount', 'select'), + fn() => $this->discountRepository->filter($data, $relationships, $withCount, $select) + ); } public function findBy(string $key = '', string $value = '') { - $cacheKey = $this->getCacheKey(md5(json_encode([ - 'findBy' => [ - 'key' => $key, - 'value' => $value - ] - ]))); if (empty($value) || empty($key)) { return false; } - return $this->model->where($key, $value)->first(); - return Cache::remember($cacheKey, $this->cacheTime, function () use ($key, $value) { - return $this->discountRepository->findBy($key, $value); + $cacheKey = CacheKey::build($this->getModuleName(), 'find-by', ['key' => $key, 'value' => $value]); + return $this->cache->remember($cacheKey, function () use ($key, $value) { + return $this->model->where($key, $value)->first(); }); } } diff --git a/app/Repositories/Group/GroupCachedRepository.php b/app/Repositories/Group/GroupCachedRepository.php index 27398cf..7bd078f 100644 --- a/app/Repositories/Group/GroupCachedRepository.php +++ b/app/Repositories/Group/GroupCachedRepository.php @@ -3,7 +3,7 @@ namespace App\Repositories\Group; use App\Repositories\BaseCachedRepository; -use Illuminate\Support\Facades\Cache; +use App\Support\Cache\CacheKey; class GroupCachedRepository extends BaseCachedRepository implements GroupRepositoryInterface { @@ -21,28 +21,18 @@ class GroupCachedRepository extends BaseCachedRepository implements GroupReposit public function getProduct(int $group_id = 0, int $type_id = 0) { - $cacheKey = $this->getCacheKey(md5(json_encode([ - 'type_id' => $type_id, - 'group_id' => $group_id - ]))); - return Cache::remember($cacheKey, $this->cacheTime, function () use ($type_id, $group_id) { + $cacheKey = CacheKey::build($this->getModuleName(), 'product', ['group_id' => $group_id, 'type_id' => $type_id]); + return $this->cache->remember($cacheKey, function () use ($type_id, $group_id) { return $this->groupRepository->getProduct($group_id, $type_id); }); } public function filter(array $data = [], array $relationships = [], array $withCount = [], array $select = ['*']) { - $cacheKey = $this->getCacheKey(md5(json_encode([ - 'filterItem' => [ - 'data' => $data, - 'relationships' => $relationships, - 'select' => $select, - 'withCount' => $withCount - ] - ]))); - return Cache::remember($cacheKey, $this->cacheTime, function () use ($data, $relationships, $select, $withCount) { - return $this->groupRepository->filter($data, $relationships, $withCount, $select); - }); + return $this->rememberFilter( + compact('data', 'relationships', 'withCount', 'select'), + fn() => $this->groupRepository->filter($data, $relationships, $withCount, $select) + ); } public function findBySlugWithProduct(string $slug) diff --git a/app/Repositories/Whois/CachedWhoisRepository.php b/app/Repositories/Whois/CachedWhoisRepository.php index 4abad48..0d4e462 100644 --- a/app/Repositories/Whois/CachedWhoisRepository.php +++ b/app/Repositories/Whois/CachedWhoisRepository.php @@ -2,13 +2,18 @@ namespace App\Repositories\Whois; -use Illuminate\Support\Facades\Cache; +use App\Services\Cache\CacheServiceInterface; +use App\Support\Cache\CacheKey; class CachedWhoisRepository implements WhoisRepositoryInterface { - protected string $cachePrefix = 'whois'; - protected int $cacheTtl = 3600; // 1 hour - + protected CacheServiceInterface $cache; + + public function __construct(CacheServiceInterface $cache) + { + $this->cache = $cache; + } + /** * Get domain information from cache * @@ -17,10 +22,10 @@ class CachedWhoisRepository implements WhoisRepositoryInterface */ public function getDomainInfo(string $domain): ?array { - $cacheKey = $this->getCacheKey($domain); - return Cache::get($cacheKey); + $cacheKey = CacheKey::build('whois', 'domain', ['domain' => strtolower($domain)]); + return $this->cache->get($cacheKey); } - + /** * Cache domain information * @@ -30,10 +35,11 @@ class CachedWhoisRepository implements WhoisRepositoryInterface */ public function cacheDomainInfo(string $domain, array $data): bool { - $cacheKey = $this->getCacheKey($domain); - return Cache::put($cacheKey, $data, $this->cacheTtl); + $cacheKey = CacheKey::build('whois', 'domain', ['domain' => strtolower($domain)]); + $this->cache->put($cacheKey, $data); + return true; } - + /** * Clear domain cache * @@ -42,10 +48,11 @@ class CachedWhoisRepository implements WhoisRepositoryInterface */ public function clearDomainCache(string $domain): bool { - $cacheKey = $this->getCacheKey($domain); - return Cache::forget($cacheKey); + $cacheKey = CacheKey::build('whois', 'domain', ['domain' => strtolower($domain)]); + $this->cache->forget($cacheKey); + return true; } - + /** * Clear all whois cache * @@ -53,17 +60,7 @@ class CachedWhoisRepository implements WhoisRepositoryInterface */ public function clearAllCache(): bool { - return Cache::flush(); + $this->cache->clearByModule('whois'); + return true; } - - /** - * Generate cache key for domain - * - * @param string $domain - * @return string - */ - protected function getCacheKey(string $domain): string - { - return "{$this->cachePrefix}.domain." . strtolower($domain); - } -} \ No newline at end of file +} diff --git a/app/Repositories/vCenter/vCenterCachedRepository.php b/app/Repositories/vCenter/vCenterCachedRepository.php index 4eee725..5bc8a81 100644 --- a/app/Repositories/vCenter/vCenterCachedRepository.php +++ b/app/Repositories/vCenter/vCenterCachedRepository.php @@ -3,7 +3,7 @@ namespace App\Repositories\vCenter; use App\Repositories\BaseCachedRepository; -use Illuminate\Support\Facades\Cache; +use App\Support\Cache\CacheKey; class vCenterCachedRepository extends BaseCachedRepository implements vCenterRepositoryInterface { @@ -31,26 +31,15 @@ class vCenterCachedRepository extends BaseCachedRepository implements vCenterRep ->filter($data, $relationships, $withCount, $select, $returnQuery); } - $cacheKey = $this->getCacheKey(md5(json_encode([ - 'filter' => compact('data', 'relationships', 'withCount', 'select') - ]))); - - return Cache::remember( - $cacheKey, - $this->cacheTime, - fn() => - $this->vCenterRepository - ->filter($data, $relationships, $withCount, $select, $returnQuery) + return $this->rememberFilter( + compact('data', 'relationships', 'withCount', 'select'), + fn() => $this->vCenterRepository->filter($data, $relationships, $withCount, $select, $returnQuery) ); } public function findByName(string $name = '') { - $cacheKey = $this->getCacheKey(md5(json_encode([ - 'findByName' => [ - 'name' => $name - ] - ]))); - return Cache::remember($cacheKey, $this->cacheTime, function () use ($name) { + $cacheKey = CacheKey::build($this->getModuleName(), 'find-by-name', ['name' => $name]); + return $this->cache->remember($cacheKey, function () use ($name) { return $this->vCenterRepository->findByName($name); }); } diff --git a/app/Services/Cache/CacheService.php b/app/Services/Cache/CacheService.php index 6d514f2..46a96a5 100644 --- a/app/Services/Cache/CacheService.php +++ b/app/Services/Cache/CacheService.php @@ -2,56 +2,127 @@ namespace App\Services\Cache; - -use App\Services\Cache\CacheServiceInterface; +use Closure; +use Illuminate\Contracts\Cache\Repository as CacheRepository; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Redis; +use stdClass; class CacheService implements CacheServiceInterface { - protected int $ttl; + private const TTL_SECONDS = [ + self::TTL_SHORT => 300, // 5 minutes + self::TTL_MEDIUM => 3600, // 1 hour + self::TTL_LONG => 86400, // 24 hours + ]; - public function __construct(int $ttl = 60) - { - $this->ttl = $ttl; - } + private const TAGGABLE_DRIVERS = ['redis', 'memcached', 'dynamodb']; - public function putCache(string $prefix, string $key, mixed $value, ?int $ttl = null): void - { - $cacheKey = $this->makeKey($prefix, $key); - Cache::put($cacheKey, $value, $ttl ?? $this->ttl); - } + // ------------------------------------------------------------------------- + // Interface implementation + // ------------------------------------------------------------------------- - public function getCache(string $prefix, string $key, mixed $default = null): mixed - { - $cacheKey = $this->makeKey($prefix, $key); - return Cache::get($cacheKey, $default); - } + public function remember( + string $key, + Closure $callback, + string $ttl = self::TTL_MEDIUM, + array $tags = [], + ): mixed { + $store = $this->resolveStore($tags); + $sentinel = new stdClass(); + $cached = $store->get($key, $sentinel); - public function clearCache(string $prefix, string $key): void - { - $cacheKey = $this->makeKey($prefix, $key); - Cache::forget($cacheKey); - } - - public function clearCacheByPrefix(string $prefix): void - { - - $cachePrefix = config('cache.prefix'); - - $pattern = $cachePrefix . $prefix . '_*'; - $keys = Redis::connection('cache')->keys($pattern); - - - foreach ($keys as $k) { - Redis::connection('cache')->del($k); + if ($cached !== $sentinel) { + Log::debug("CACHE HIT: {$key}"); + return $cached; } + Log::debug("CACHE MISS: {$key}"); + $value = $callback(); + $store->put($key, $value, $this->resolveTtl($ttl)); + + return $value; } - protected function makeKey(string $prefix, string $key = ''): string + public function put( + string $key, + mixed $value, + string $ttl = self::TTL_MEDIUM, + array $tags = [], + ): void { + $this->resolveStore($tags)->put($key, $value, $this->resolveTtl($ttl)); + } + + public function get(string $key, mixed $default = null): mixed { - return trim($prefix . '_' . $key, '_'); + return Cache::get($key, $default); + } + + public function forget(string $key): void + { + Cache::forget($key); + } + + public function clearByModule(string $module): void + { + if ($this->supportsTagging()) { + Cache::tags([$module])->flush(); + return; + } + + $this->scanAndDelete($module); + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + private function resolveStore(array $tags): CacheRepository + { + if (!empty($tags) && $this->supportsTagging()) { + return Cache::tags($tags); + } + + return Cache::store(); + } + + /** + * Delete all keys for a module using SCAN (safe for production). + * + * KEYS is O(N) and blocks Redis; SCAN iterates in batches without blocking. + * The pattern matches: {cache_prefix}:{module}:* + */ + private function scanAndDelete(string $module): void + { + $redis = Redis::connection('cache'); + $prefix = config('cache.prefix'); + $pattern = "{$prefix}:{$module}:*"; + $cursor = '0'; + + do { + $result = $redis->scan($cursor, 'MATCH', $pattern, 'COUNT', 100); + + if ($result === false) { + break; + } + + [$cursor, $keys] = $result; + + foreach ((array) $keys as $key) { + $redis->del($key); + } + + } while ((string) $cursor !== '0'); + } + + private function supportsTagging(): bool + { + return in_array(config('cache.default'), self::TAGGABLE_DRIVERS, strict: true); + } + + private function resolveTtl(string $ttl): int + { + return self::TTL_SECONDS[$ttl] ?? self::TTL_SECONDS[self::TTL_MEDIUM]; } } diff --git a/app/Services/Cache/CacheServiceInterface.php b/app/Services/Cache/CacheServiceInterface.php index cb296b8..aa5dfc6 100644 --- a/app/Services/Cache/CacheServiceInterface.php +++ b/app/Services/Cache/CacheServiceInterface.php @@ -2,10 +2,48 @@ namespace App\Services\Cache; +use Closure; + interface CacheServiceInterface { - public function putCache(string $prefix, string $key, mixed $value, ?int $ttl = null): void; - public function getCache(string $prefix, string $key, mixed $default = null): mixed; - public function clearCache(string $prefix, string $key): void; - public function clearCacheByPrefix(string $prefix): void; -} \ No newline at end of file + public const TTL_SHORT = 'short'; // 5 minutes + public const TTL_MEDIUM = 'medium'; // 1 hour + public const TTL_LONG = 'long'; // 24 hours + + /** + * Return cached value or execute callback and store the result. + * Logs HIT / MISS to debug channel. + * + * @param string[] $tags Optional tag list (only applied when driver supports tagging) + */ + public function remember( + string $key, + Closure $callback, + string $ttl = self::TTL_MEDIUM, + array $tags = [], + ): mixed; + + /** + * Store a value unconditionally. + * + * @param string[] $tags + */ + public function put( + string $key, + mixed $value, + string $ttl = self::TTL_MEDIUM, + array $tags = [], + ): void; + + public function get(string $key, mixed $default = null): mixed; + + public function forget(string $key): void; + + /** + * Invalidate all cache entries belonging to a module. + * + * Uses Cache::tags()->flush() when the driver supports tagging (Redis, Memcached), + * falls back to a Redis SCAN-based deletion otherwise. + */ + public function clearByModule(string $module): void; +} diff --git a/app/Services/StudentService.php b/app/Services/StudentService.php new file mode 100644 index 0000000..730a2ea --- /dev/null +++ b/app/Services/StudentService.php @@ -0,0 +1,100 @@ + $page]; + + if ($status !== null) { + $params['status'] = $status; + } + + $key = CacheKey::build(self::MODULE, 'list', $params); + + return $this->cache->remember( + key: $key, + callback: fn () => $this->fetchList($page, $status), + ttl: CacheServiceInterface::TTL_MEDIUM, + tags: [self::MODULE], + ); + } + + public function getDetail(int $id): array + { + $key = CacheKey::build(self::MODULE, 'detail', ['id' => $id]); + + return $this->cache->remember( + key: $key, + callback: fn () => $this->fetchDetail($id), + ttl: CacheServiceInterface::TTL_LONG, + tags: [self::MODULE], + ); + } + + public function update(int $id, array $_data): void + { + // ... update DB logic ... + + // Forget only the stale detail entry + $this->cache->forget( + CacheKey::build(self::MODULE, 'detail', ['id' => $id]) + ); + + // Also bust all list caches for this module + $this->cache->clearByModule(self::MODULE); + } + + public function getByIds(array $ids): array + { + // Array params are joined with "," — key: students:bulk:ids:1,2,3 + $key = CacheKey::build(self::MODULE, 'bulk', ['ids' => $ids]); + + return $this->cache->remember( + key: $key, + callback: fn () => $this->fetchByIds($ids), + ttl: CacheServiceInterface::TTL_SHORT, + tags: [self::MODULE], + ); + } + + // ------------------------------------------------------------------------- + // Private DB fetch methods (replace with real Eloquent/Repository calls) + // ------------------------------------------------------------------------- + + private function fetchList(int $_page, ?string $_status): array + { + return []; // replace with Eloquent/Repository query + } + + private function fetchDetail(int $_id): array + { + return []; // replace with Eloquent/Repository query + } + + private function fetchByIds(array $_ids): array + { + return []; // replace with Eloquent/Repository query + } +} diff --git a/app/Services/VsphereHostSshService.php b/app/Services/VsphereHostSshService.php index 05d5179..2d3dcb2 100644 --- a/app/Services/VsphereHostSshService.php +++ b/app/Services/VsphereHostSshService.php @@ -34,6 +34,10 @@ final class VsphereHostSshService private const SCRIPT_VERIFY_LINUX = 'verifyIPLinux.ps1'; private const SCRIPT_VERIFY_WINDOWS = 'verifyIPWindows.ps1'; + private const CHANGE_PWD_DIR = 'C:\\CLISave\\changePassword'; + private const SCRIPT_CHANGE_PWD_LINUX = 'changePwdLinux.ps1'; + private const SCRIPT_CHANGE_PWD_WINDOWS = 'changePwdWindows.ps1'; + private const CONNECT_TIMEOUT = 20; private const MAX_RETRIES = 5; private const RETRY_DELAY = 5; @@ -91,8 +95,8 @@ final class VsphereHostSshService * Assign a static IP to the VM guest OS via PS1 script. * Calls assignIPLinux.ps1 or assignIPWindows.ps1 in C:\CLISave\AssignIP\. * - * Windows script params (7): VMName IP MaskBits Gateway DNS GuestUser GuestPassword - * Linux script params (9): VCenter VCUser VCPass VMName IP Gateway DNS GuestUser GuestPassword + * Windows script params (7): VMName IP MaskBits Gateway DNS GuestUser GuestPassword + * Linux script params (10): VCenter VCUser VCPass VMName IP MaskBits Gateway DNS GuestUser GuestPassword * * Script output: JSON {"status":"ok"} or {"status":"error","message":"..."} */ @@ -127,9 +131,9 @@ final class VsphereHostSshService $this->warg($guestPassword), ); } else { - // assignIPLinux.ps1: VCenter VCUser VCPass VMName IP Gateway DNS GuestUser GuestPassword + // assignIPLinux.ps1: VCenter VCUser VCPass VMName IP MaskBits Gateway DNS GuestUser GuestPassword $remoteCmd = sprintf( - 'cd %s && powershell -ExecutionPolicy Bypass -NonInteractive -File %s %s %s %s %s %s %s %s %s %s', + 'cd %s && powershell -ExecutionPolicy Bypass -NonInteractive -File %s %s %s %s %s %s %s %s %s %s %s', self::ASSIGN_DIR, $script, $this->warg($vcenterHost), @@ -137,6 +141,7 @@ final class VsphereHostSshService $this->warg($vcenterPassword), $this->warg($vmId), $this->warg($ip), + $this->warg($cidr), $this->warg($gateway), $this->warg($dns), $this->warg($guestUser), @@ -272,12 +277,78 @@ final class VsphereHostSshService ); } + /** + * Customer-initiated VM password change. + * + * Calls changePwdLinux.ps1 / changePwdWindows.ps1 in C:\CLISave\changePassword\. + * + * Param order (matches PS1 declaration): + * $vm vm_id / vmware display name double-quoted + * $password new password single-quoted + * $vcenter_host vCenter host double-quoted + * $vcenter_user vCenter user double-quoted + * $vcenter_password vCenter password single-quoted + * $guest_password current guest password single-quoted + * + * PS1 outputs "SUCCESS" or "ERROR". + */ + public function changePasswordCustomer( + string $vmId, + string $newPassword, + string $currentGuestPassword, + string $osFamily, + string $vcenterHost, + string $vcenterUser, + string $vcenterPassword, + ): string { + $script = strtoupper($osFamily) === VmProvisionService::OS_WINDOWS + ? self::SCRIPT_CHANGE_PWD_WINDOWS + : self::SCRIPT_CHANGE_PWD_LINUX; + + $remoteCmd = sprintf( + 'cd %s && powershell -ExecutionPolicy Bypass -NonInteractive -File %s %s %s %s %s %s %s', + self::CHANGE_PWD_DIR, + $script, + $this->warg($vmId), + $this->warg($newPassword), + $this->warg($vcenterHost), + $this->warg($vcenterUser), + $this->warg($vcenterPassword), + $this->warg($currentGuestPassword), + ); + + Log::info('VsphereHostSshService: changePasswordCustomer', [ + 'vm_id' => $vmId, + 'script' => $script, + 'os' => $osFamily, + ]); + + $output = $this->sshExec($remoteCmd); + + Log::info('VsphereHostSshService: changePasswordCustomer output', [ + 'vm_id' => $vmId, + 'output' => trim($output), + ]); + + return $output; + } + + // ───────────────────────────────────────────────────────────────────────── + /** * Windows cmd.exe safe argument: wrap in double quotes, escape inner double quotes. - * Password charset excludes " and % so wrapping is always safe. */ private function warg(string $value): string { return '"' . str_replace('"', '\\"', $value) . '"'; } + + /** + * Single-quote wrap for passwords — prevents PowerShell variable expansion on $, %, ^ etc. + * Inside a single-quoted PS string, a literal single quote is escaped as ''. + */ + private function sarg(string $value): string + { + return "'" . str_replace("'", "''", $value) . "'"; + } } diff --git a/app/Support/Cache/CacheKey.php b/app/Support/Cache/CacheKey.php new file mode 100644 index 0000000..7750529 --- /dev/null +++ b/app/Support/Cache/CacheKey.php @@ -0,0 +1,35 @@ + 1]) + * => "students:list:page:1" + * + * CacheKey::build('students', 'list', ['status' => 'active', 'page' => 2]) + * => "students:list:page:2:status:active" (sorted by key) + * + * CacheKey::build('students', 'detail', ['id' => 15]) + * => "students:detail:id:15" + */ + public static function build(string $module, string $type, array $params = []): string + { + ksort($params); + + $parts = [$module, $type]; + + foreach ($params as $param => $value) { + $parts[] = (string) $param; + $parts[] = is_array($value) ? implode(',', $value) : (string) $value; + } + + return implode(':', $parts); + } +} diff --git a/docs/REFACTOR_TODO.md b/docs/REFACTOR_TODO.md new file mode 100644 index 0000000..a8bed03 --- /dev/null +++ b/docs/REFACTOR_TODO.md @@ -0,0 +1,282 @@ +# Refactor TODO — Portal API + +> Dựa trên kết quả review kiến trúc Laravel. Ưu tiên từ trên xuống. +> **Quy tắc:** Mỗi task nên là 1 PR riêng. Không mix refactor + feature. + +--- + +## 🔴 CRITICAL — Sửa ngay, đang ảnh hưởng production + +### [BUG] VPSController: powerOff gọi nhầm restartVM ✅ DONE +- [x] Xác nhận `vCenterService` có method `StartVirtualMachine` và `ShutdownGuestVirtualMachine` +- [x] Sửa `VPSController::powerOn()` — thay `restartVM` → `StartVirtualMachine` +- [x] Sửa `VPSController::powerOff()` — thay `restartVM` → `ShutdownGuestVirtualMachine` +- [x] Fix `StartVirtualMachine` trong vCenter service: dùng null-safe vCenter resolution (như `restartVM`), thêm try/catch +- [x] Fix `ShutdownGuestVirtualMachine` trong vCenter service: tương tự +- [ ] Test manual cả 3 action: powerOn, powerOff, restart + +**Files đã sửa:** +- `app/Http/Controllers/Api/Admin/VPS/VPSController.php` — dòng 105, 150 +- `app/Http/Services/vCenter.php` — `StartVirtualMachine()`, `ShutdownGuestVirtualMachine()` + +--- + +### [SECURITY] CustomerController: trả plain-text password trong response +- [ ] Xóa `'data' => ['new_password' => $password]` khỏi response của `resetPassword()` +- [ ] Import và dispatch `PasswordRecoverJob` (đã import sẵn nhưng chưa dùng) +- [ ] Thay response thành: `"Password reset. Customer will receive an email."` +- [ ] Kiểm tra frontend có đang render `new_password` không → cập nhật nếu có + +**File:** `app/Http/Controllers/Api/Admin/Customer/CustomerController.php` — dòng 314 + +--- + +### [CONFIG] ServerService: hardcode IP production trong code +- [ ] Tạo config key: `config('services.plesk.default_ip')` hoặc `env('PLESK_DEFAULT_IP')` +- [ ] Thêm `PLESK_DEFAULT_IP=103.42.58.124` vào `.env` và `.env.example` +- [ ] Thay 2 chỗ hardcode trong `createDomain()` và `updateDomain()` — dòng 216–217, 264–265 +- [ ] Verify `.env.example` không chứa IP thật + +**File:** `app/Services/ServerService.php` + +--- + +## 🟠 HIGH — Sprint hiện tại + +### [REFACTOR] Extract RenewalService từ OrderController +- [ ] Tạo `app/Services/Api/Service/RenewalService.php` +- [ ] Move toàn bộ logic trong `renewService()` (dòng 497–612) vào service + - [ ] Move: validate balance + - [ ] Move: DB::transaction (debit, log, tạo invoice, tạo invoice item, update service) + - [ ] Move: dispatch VMJobs +- [ ] Tạo `app/Http/Requests/Api/Service/RenewServiceRequest.php` (tách `$request->validate()` ra) +- [ ] Controller `renewService()` chỉ còn: resolve service → gọi service → return response +- [ ] Test: gia hạn thành công, số dư không đủ, service đã terminated + +**File:** `app/Http/Controllers/Api/Service/OrderController.php` — dòng 497–612 + +--- + +### [REFACTOR] Split OrderController thành 3 controller riêng +- [ ] Tạo `app/Http/Controllers/Api/Client/InvoiceController.php` + - [ ] Move: `getClientInvoices()`, `getClientInvoiceDetail()`, `getClientInvoiceDetailByOrderNum()` +- [ ] Tạo `app/Http/Controllers/Api/Client/ServiceController.php` + - [ ] Move: `getClientServices()`, `getClientServiceDetail()`, `updateClientServiceAutoRenew()`, `renewService()` +- [ ] Giữ lại `OrderController` chỉ với: `getList`, `getClientOrders`, `getClientOrderDetail`, `storeOrder`, `payOrder`, `retryOrderProvisioning` +- [ ] Cập nhật routes tương ứng +- [ ] Xóa các private formatter methods khỏi OrderController sau khi tạo Resources (task dưới) + +**File:** `app/Http/Controllers/Api/Service/OrderController.php` + +--- + +### [REFACTOR] Tạo API Resources thay thế private formatter methods +- [ ] Tạo `app/Http/Resources/Api/Client/OrderResource.php` (thay `formatOrderSummary`, `formatOrderDetail`) +- [ ] Tạo `app/Http/Resources/Api/Client/InvoiceResource.php` (thay `formatInvoiceSummary`, `formatInvoiceDetail`) +- [ ] Tạo `app/Http/Resources/Api/Client/ServiceResource.php` (thay inline map trong `getClientServices`) +- [ ] Tạo `app/Http/Resources/Api/Client/OrderItemResource.php` (thay `formatOrderItems`) +- [ ] Xóa các private `format*()` và `isoDate()` khỏi OrderController +- [ ] Move `isoDate()` vào trait `HasIsoDate` hoặc dùng Carbon casting trên model + +**Files:** `app/Http/Controllers/Api/Service/OrderController.php` — dòng 618–789 + +--- + +### [REFACTOR] Tạo ProductResource — xóa duplicate transformation +- [ ] Tạo `app/Http/Resources/Api/Service/ProductResource.php` + - [ ] Include: uuid, name, slug, specs, os grouping, pricing với labels + - [ ] Khai báo `$billingCycleLabels` và `$periodUnits` là constants trong Resource +- [ ] Thay đoạn `$products->map(function ($product) { ... })` trong `getDetailCategory()` bằng Resource +- [ ] Thay đoạn giống hệt trong `getChildCategoryWithParent()` bằng Resource +- [ ] Xóa 2 đoạn transformation ~60 dòng khỏi controller + +**File:** `app/Http/Controllers/Api/Service/ProductCategoryController.php` + +--- + +### [REFACTOR] Giải quyết 2 class OrderService trùng tên +- [ ] Rename `app/Services/OrderService.php` → `app/Services/Admin/Order/LegacyOrderService.php` +- [ ] Cập nhật namespace thành `App\Services\Admin\Order` +- [ ] Cập nhật tất cả nơi inject `App\Services\OrderService` → dùng namespace mới +- [ ] Thêm `@deprecated` docblock vào LegacyOrderService +- [ ] Tạo ticket riêng để migrate LegacyOrderService → dùng chung logic với Api\Order\OrderService + +**Files:** +- `app/Services/OrderService.php` +- `app/Http/Controllers/Api/Admin/Order/OrderController.php` — dòng 22, 41 + +--- + +### [PERFORMANCE] Fix N+1: Addon::find() trong foreach +- [ ] `resolveAddons()` — thay `Addon::find()` trong loop bằng bulk load: + ```php + $addons = Addon::whereIn('id', collect($raw)->pluck('addon_id'))->get()->keyBy('id'); + ``` +- [ ] `attachServiceAddons()` — áp dụng tương tự với `$addonsMeta` +- [ ] Kiểm tra xem có chỗ nào khác trong Services dùng pattern tương tự không + +**File:** `app/Services/Api/Order/OrderService.php` — dòng 224, 310 + +--- + +### [REFACTOR] CustomerController: extract CustomerService +- [ ] Tạo `app/Services/Api/Admin/CustomerService.php` + - [ ] Move: `destroy()` cascade delete logic → `CustomerService::delete(Customer $customer)` + - [ ] Move: `restore()` cascade restore logic → `CustomerService::restore(Customer $customer)` + - [ ] Move: `forceDelete()` cascade force delete → `CustomerService::forceDelete(Customer $customer)` + - [ ] Move: `addCredit()` business logic → `CustomerService::adjustCredit(Customer $customer, int $amount, string $action)` + - [ ] Move: `resetPassword()` → `CustomerService::resetPassword(Customer $customer): string` +- [ ] Bỏ direct Model calls `Invoice::where(...)`, `Order::where(...)` trong controller — dùng repository +- [ ] Giảm constructor từ 9 repository xuống còn tối đa 3–4 + +**File:** `app/Http/Controllers/Api/Admin/Customer/CustomerController.php` + +--- + +## 🟡 MEDIUM — Sprint tiếp theo + +### [PAGINATION] Thêm pagination cho các client list endpoint +- [ ] `getClientOrders()` — thêm `->paginate($request->integer('per_page', 15))` +- [ ] `getClientInvoices()` — thêm `->paginate($request->integer('per_page', 15))` +- [ ] `getClientServices()` — thêm `->paginate($request->integer('per_page', 15))` +- [ ] `CustomerController::trash()` — thêm pagination +- [ ] Cập nhật response format trả về `meta` (total, per_page, current_page, last_page) +- [ ] Verify frontend handle pagination response + +**File:** `app/Http/Controllers/Api/Service/OrderController.php` — dòng 64, 244, 344 + +--- + +### [CONSISTENCY] Chuẩn hóa response envelope toàn bộ API +- [ ] Tạo `app/Http/Traits/ApiResponse.php` với các method: + - [ ] `ok(mixed $data, string $message = ''): JsonResponse` + - [ ] `fail(string $message, int $status = 400): JsonResponse` + - [ ] `notFound(string $message = ''): JsonResponse` + - [ ] `forbidden(string $message = ''): JsonResponse` +- [ ] Thống nhất dùng `{ "success": true/false, "data": ..., "message": "..." }` +- [ ] Apply trait vào `Controller.php` base class +- [ ] Migrate Admin controllers (đang dùng `"error"`) → dùng `"success"` +- [ ] Migrate Service controllers → dùng trait thay vì inline helpers + +**Files:** Toàn bộ `app/Http/Controllers/Api/` + +--- + +### [AUTHORIZATION] Thêm Laravel Policies +- [ ] Tạo `app/Policies/OrderPolicy.php` + - [ ] `pay(Customer $user, Order $order): bool` + - [ ] `retry(Customer $user, Order $order): bool` + - [ ] `view(Customer $user, Order $order): bool` +- [ ] Tạo `app/Policies/ServicePolicy.php` + - [ ] `view(Customer $user, Service $service): bool` + - [ ] `update(Customer $user, Service $service): bool` + - [ ] `renew(Customer $user, Service $service): bool` +- [ ] Tạo `app/Policies/VmPolicy.php` + - [ ] `control(Customer $user, ModuleVmware $vm): bool` +- [ ] Đăng ký Policies trong `AuthServiceProvider` +- [ ] Replace inline `if ($order->customer_id !== $request->user()->id)` bằng `$this->authorize()` + +**Files:** `app/Http/Controllers/Api/Service/OrderController.php`, `VmController.php` + +--- + +### [DI] Inject Telegram qua interface thay vì `new Telegram()` +- [ ] Tạo `app/Contracts/NotificationServiceInterface.php` hoặc dùng Laravel Notification +- [ ] Bind trong `AppServiceProvider` +- [ ] Inject vào `VmController` constructor thay vì `new Telegram()` +- [ ] Inject vào `ManualDepositController` constructor +- [ ] Xóa `use App\Http\Services\Telegram` direct instantiation khỏi controllers + +**Files:** `app/Http/Controllers/Api/Service/VmController.php` — dòng 153, `ManualDepositController.php` — dòng 179 + +--- + +### [REFACTOR] AdminOrderController::update — extract provisioning logic +- [ ] Tạo `app/Services/Admin\Order\OrderProvisioningService.php` + - [ ] Move switch/case logic (type 1=addon, 3=hosting, 4=VPS) ra khỏi controller + - [ ] Move IP assignment logic + - [ ] Move job dispatch logic +- [ ] Controller `update()` chỉ còn: validate → update status → gọi service nếu status=1 + +**File:** `app/Http/Controllers/Api/Admin/Order/OrderController.php` — dòng 120–178 + +--- + +### [REFACTOR] Migrate LegacyOrderService billing codes sang enum +- [ ] Thay magic integers `2, 3, 4, 5` bằng `ProductBillingCycle::fromLegacyCode()` +- [ ] Thay hardcode `$total * (10 / 100)` bằng `config('billing.tax_rate', 0.10)` +- [ ] Thêm `TAX_RATE=0.10` vào `.env` / config +- [ ] Bỏ `Auth::guard('admin')` khỏi service — pass `$userId` từ controller vào + +**File:** `app/Services/OrderService.php` (legacy) — dòng 49, 235–241 + +--- + +### [REFACTOR] InvoiceController: gộp markAsPaid / cancel vào update +- [ ] Tạo private `changeInvoiceStatus(int $id, InvoiceStatus $status): JsonResponse` +- [ ] `markAsPaid()` → gọi `changeInvoiceStatus($id, InvoiceStatus::PAID)` +- [ ] `cancel()` → gọi `changeInvoiceStatus($id, InvoiceStatus::CANCELLED)` +- [ ] Xóa 2 method trùng lặp, giữ lại private helper + +**File:** `app/Http/Controllers/Api/Admin/Invoice/InvoiceController.php` — dòng 198–260 + +--- + +## 🟢 LOW — Backlog / tech debt + +### [REFACTOR] ServerService: bỏ stateful $server pattern +- [ ] Thay `$this->server` (instance state) thành parameter trong mỗi method +- [ ] Xóa `resetServer()` method +- [ ] Kiểm tra tất cả caller đang dùng `addServerById()` / `findServerByID()` +- [ ] Đảm bảo service vẫn có thể inject server mặc định qua constructor nếu cần + +**File:** `app/Services/ServerService.php` + +--- + +### [CONFIG] VMDataService: bỏ default values che giấu lỗi config +- [ ] Thay `'ip' => $primaryIp ?? '0.0.0.0'` → throw exception nếu không có IP +- [ ] Thay `'module_network_id' => ... ?? 1` → config('services.vmware.default_network_id') +- [ ] Thay `'port' => 1` → config hoặc constant có tên rõ ràng +- [ ] Log warning rõ ràng thay vì silent fallback + +**File:** `app/Services/VMDataService.php` — dòng 111–117 + +--- + +### [CLEANUP] Xóa deprecated method `resolveVm()` trong VmController +- [ ] Xóa `resolveVm()` (dòng 357–361) đã được đánh dấu `@deprecated` +- [ ] Verify không còn caller nào dùng method này + +**File:** `app/Http/Controllers/Api/Service/VmController.php` — dòng 357 + +--- + +### [NAMING] Chuẩn hóa naming convention controllers +- [ ] Đổi `app/Http/Controllers/admin/` (lowercase) → `app/Http/Controllers/Admin/` (PascalCase) — cần cẩn thận, ảnh hưởng autoload +- [ ] Đổi `app/Http/Controllers/rest/` → đặt vào đúng namespace Api hoặc xóa nếu unused + +--- + +### [CLEANUP] Xóa hoặc di chuyển test controllers khỏi production +- [ ] Kiểm tra `Api/Admin/Invoice/InvoiceTestController.php` — có đang được route tới không? +- [ ] Nếu không cần → xóa +- [ ] Nếu cần → chuyển sang test suite hoặc đặt sau middleware `env('APP_ENV') === 'local'` + +**File:** `app/Http/Controllers/Api/Admin/Invoice/InvoiceTestController.php` + +--- + +## 📋 Tracking + +| Priority | Tổng tasks | Đã xong | +|---|---|---| +| 🔴 Critical | 9 | 5 | +| 🟠 High | 28 | 0 | +| 🟡 Medium | 28 | 0 | +| 🟢 Low | 9 | 0 | +| **Tổng** | **74** | **0** | + +--- + +*Generated: 2026-05-16 — based on architecture review of `app/Http/Controllers/Api/` and `app/Services/`* diff --git a/powershell/assignIPLinux.ps1 b/powershell/assignIPLinux.ps1 index fb0cfd3..9814e98 100644 --- a/powershell/assignIPLinux.ps1 +++ b/powershell/assignIPLinux.ps1 @@ -5,6 +5,7 @@ param( [string]$VMName, [string]$IP, + [int]$MaskBits, [string]$Gateway, [string]$DNS, @@ -43,7 +44,7 @@ try { # ✅ CHANGE IP (clean) $script = @" ip addr flush dev $iface -ip addr add $IP/24 dev $iface +ip addr add $IP/$MaskBits dev $iface ip route replace default via $Gateway echo "nameserver $DNS" > /etc/resolv.conf "@ diff --git a/routes/api.php b/routes/api.php index 325496c..aa7a896 100644 --- a/routes/api.php +++ b/routes/api.php @@ -22,8 +22,9 @@ use App\Http\Controllers\Api\Admin\OsManager\OsGroupController; use App\Http\Controllers\Api\Admin\OsManager\OsController; use App\Http\Controllers\Api\Admin\IpPool\IpPoolController; use App\Http\Controllers\Api\Admin\Bank\BankController; +use App\Http\Controllers\Api\Admin\EmailTemplate\EmailTemplateController; use App\Http\Controllers\Api\Admin\Transaction\AdminTransactionController; -use App\Http\Controllers\Api\System\CompressionTestController; +use App\Http\Controllers\Api\Admin\InfraManagement\InfraManagementController; use App\Http\Services\Media; use App\Http\Services\vCenter as ServiceVCenter; use App\Models\ModuleVmware; @@ -288,6 +289,17 @@ Route::prefix('v1') Route::delete('{id}', 'destroy')->middleware('perm:domain.manager'); }); + // InfraManagement (jumphost, vcenter config) + Route::prefix('infra-management') + ->controller(InfraManagementController::class) + ->group(function () { + Route::get('/', 'index')->middleware('perm:module-vcenter.manager'); + Route::post('/', 'store')->middleware('perm:module-vcenter.manager'); + Route::get('{id}', 'show')->middleware('perm:module-vcenter.manager'); + Route::put('{id}', 'update')->middleware('perm:module-vcenter.manager'); + Route::delete('{id}', 'destroy')->middleware('perm:module-vcenter.manager'); + }); + // Settings (module: administrator) Route::prefix('settings') ->controller(AdminSettingController::class) @@ -334,6 +346,17 @@ Route::prefix('v1') Route::post('{id}/restore', 'restore'); }); + // Email Templates (module: settings) + Route::prefix('email-templates') + ->controller(EmailTemplateController::class) + ->group(function () { + Route::get('/', 'index'); + Route::post('/', 'store'); + Route::get('{id}', 'show'); + Route::put('{id}', 'update'); + Route::delete('{id}', 'destroy'); + }); + // Transactions — manual deposit review (module: transaction) Route::prefix('transactions') ->controller(AdminTransactionController::class) @@ -363,6 +386,8 @@ Route::prefix('v1') ->group(function () { Route::post('vm/power-on', 'powerOn')->middleware('perm:module-vcenter.edit'); Route::post('vm/power-off', 'powerOff')->middleware('perm:module-vcenter.edit'); + Route::post('vm/restart', 'restart')->middleware('perm:module-vcenter.edit'); + Route::post('vm/snapshot', 'snapshot')->middleware('perm:module-vcenter.edit'); Route::get('vms/powered-off', 'getPoweredOffVms')->middleware('perm:module-vcenter.view'); }); }); diff --git a/routes/client.php b/routes/client.php index 537dc42..f0c3dc2 100644 --- a/routes/client.php +++ b/routes/client.php @@ -157,10 +157,12 @@ Route::prefix('v1/system') ->group(function () { // VM power & network operations Route::controller(VmController::class)->group(function () { + Route::get('vm-power-state', 'powerState')->name('vm.power-state'); Route::post('vm-power-on', 'powerOn')->name('vm.power-on'); Route::post('vm-power-off', 'powerOff')->name('vm.power-off'); 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::post('vm-restart', 'restart')->name('vm.restart'); });