From a6b641015ee36cfe14ca46486997f9349d9e4344 Mon Sep 17 00:00:00 2001 From: baodev0 Date: Tue, 27 Oct 2026 14:24:13 +0700 Subject: [PATCH] =?UTF-8?q?feat(service):=20=C4=91=E1=BB=95i=20s=E1=BA=A3n?= =?UTF-8?q?=20ph=E1=BA=A9m=20c=E1=BB=A7a=20d=E1=BB=8Bch=20v=E1=BB=A5,=202F?= =?UTF-8?q?A=20b=E1=BA=AFt=20bu=E1=BB=99c,=20=C4=91=C3=ADnh=20k=C3=A8m=20c?= =?UTF-8?q?ampaign=20mail,=20thu=20h=E1=BB=93i=20IP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../Concerns/ManagesIpPoolAllocation.php | 13 ++++ .../MailCampaign/MailCampaignController.php | 32 +++++++- app/Jobs/Mail/SendMailCampaignRecipient.php | 1 + app/Models/MailCampaign.php | 2 + .../Concerns/FiltersBillingRecords.php | 2 +- app/Services/Api/Client/ClientAuthService.php | 1 + app/Services/IpPool/IpReleaseService.php | 75 +++++++++++++++++++ .../VmCredentialsEmailDataBuilder.php | 12 ++- app/Support/ClientPassword.php | 20 +++++ ...02_enable_two_factor_for_all_customers.php | 24 ++++++ ...dd_attachments_to_mail_campaigns_table.php | 22 ++++++ routes/api/admin/infrastructure.php | 1 + routes/api/admin/system.php | 1 + 13 files changed, 197 insertions(+), 9 deletions(-) create mode 100644 app/Services/IpPool/IpReleaseService.php create mode 100644 app/Support/ClientPassword.php create mode 100644 database/migrations/2026_10_27_000002_enable_two_factor_for_all_customers.php create mode 100644 database/migrations/2026_10_27_000003_add_attachments_to_mail_campaigns_table.php diff --git a/app/Http/Controllers/Api/Admin/IpPool/Concerns/ManagesIpPoolAllocation.php b/app/Http/Controllers/Api/Admin/IpPool/Concerns/ManagesIpPoolAllocation.php index f0b7416..058f2e3 100644 --- a/app/Http/Controllers/Api/Admin/IpPool/Concerns/ManagesIpPoolAllocation.php +++ b/app/Http/Controllers/Api/Admin/IpPool/Concerns/ManagesIpPoolAllocation.php @@ -6,6 +6,7 @@ use App\Models\Customer; use App\Models\IpPool; use App\Models\IpPoolLog; use App\Services\IpPool\IpAllocationService; +use App\Services\IpPool\IpReleaseService; use App\Support\MailAttachments; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -46,6 +47,18 @@ trait ManagesIpPoolAllocation return response()->json(['error' => false, 'message' => "Đã thu hồi {$count} IP và gửi email thông báo"]); } + public function release(Request $request, IpReleaseService $release): JsonResponse + { + $data = $request->validate([ + 'ids' => 'required|array|min:1|max:1000', + 'ids.*' => 'integer', + 'note' => 'nullable|string|max:2000', + ]); + $count = $release->release($data['ids'], $data['note'] ?? null, $request->user('admin')); + + return response()->json(['error' => false, 'message' => "Đã giải phóng {$count} IP khỏi dịch vụ/VM"]); + } + public function logs(int $id): JsonResponse { $pool = IpPool::withTrashed()->findOrFail($id); diff --git a/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php b/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php index 9aa674d..c716782 100644 --- a/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php +++ b/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php @@ -13,6 +13,7 @@ use App\Models\MailCampaign; use App\Models\Template; use App\Services\Email\MailCampaignRenderer; use App\Support\AutoEmailGate; +use App\Support\MailAttachments; use App\Support\MailPlaceholders; use App\Support\MailSettings; use Illuminate\Http\JsonResponse; @@ -20,6 +21,8 @@ use Illuminate\Http\Request; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Storage; +use Symfony\Component\HttpFoundation\StreamedResponse; use Illuminate\Validation\ValidationException; final class MailCampaignController extends Controller @@ -106,6 +109,7 @@ final class MailCampaignController extends Controller ...$this->mapCampaign($campaign), 'cc' => $campaign->cc, 'bcc' => $campaign->bcc, + 'attachments' => collect($campaign->attachments)->pluck('name')->all(), 'variables' => $campaign->variables ?? [], 'logs' => $logs, ], @@ -204,6 +208,17 @@ final class MailCampaignController extends Controller ]); } + public function downloadAttachment(string $uuid, int $index): StreamedResponse|JsonResponse + { + $file = MailCampaign::where('uuid', $uuid)->first()?->attachments[$index] ?? null; + + if (!$file || !Storage::disk($file['disk'])->exists($file['path'])) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy tệp đính kèm'], 404); + } + + return Storage::disk($file['disk'])->download($file['path'], $file['name']); + } + public function resolveEmails(Request $request): JsonResponse { $data = $request->validate([ @@ -240,6 +255,7 @@ final class MailCampaignController extends Controller 'name' => 'nullable|string|max:255', 'cc' => 'nullable|string|max:1000', 'bcc' => 'nullable|string|max:1000', + ...MailAttachments::rules(), ], [ ...self::EMAIL_MESSAGES, 'uuids.*.exists' => 'Khách hàng đã chọn không còn tồn tại.', @@ -277,6 +293,12 @@ final class MailCampaignController extends Controller 'created_by' => $admin->id ?? null, ])->save(); + if (!empty($data['attachments'])) { + $campaign->update([ + 'attachments' => MailAttachments::store($data['attachments'], "mail-campaigns/{$campaign->uuid}"), + ]); + } + foreach ($recipients as $email => $customerId) { SendMailCampaignRecipient::dispatch($campaign->id, $customerId, $email); } @@ -427,13 +449,21 @@ final class MailCampaignController extends Controller ->toBase(); } + private function displaySubject(MailCampaign $campaign): string + { + $values = array_filter($campaign->variables ?? [], fn ($value) => $value !== null && $value !== ''); + $subject = MailPlaceholders::fill((string) $campaign->subject, $values, escapeHtml: false); + + return trim(preg_replace('/[\s|\-–,:]+$/u', '', trim($subject)) ?? $subject); + } + private function mapCampaign(MailCampaign $campaign): array { return [ 'id' => $campaign->id, 'uuid' => $campaign->uuid, 'name' => $campaign->name, - 'subject' => $campaign->subject, + 'subject' => $this->displaySubject($campaign), 'template_id' => $campaign->template_id, 'template_name' => $campaign->template->name ?? null, 'total_recipients' => $campaign->total_recipients, diff --git a/app/Jobs/Mail/SendMailCampaignRecipient.php b/app/Jobs/Mail/SendMailCampaignRecipient.php index 4dac82a..37e323d 100644 --- a/app/Jobs/Mail/SendMailCampaignRecipient.php +++ b/app/Jobs/Mail/SendMailCampaignRecipient.php @@ -61,6 +61,7 @@ class SendMailCampaignRecipient implements ShouldQueue $rendered['subject'], $rendered['html'], $mailConfig, + storedFiles: $campaign->attachments ?? [], )) === null; } catch (\Throwable $e) { $failed = true; diff --git a/app/Models/MailCampaign.php b/app/Models/MailCampaign.php index a67ccb8..fcf6d00 100644 --- a/app/Models/MailCampaign.php +++ b/app/Models/MailCampaign.php @@ -25,6 +25,7 @@ class MailCampaign extends Model 'subject', 'message', 'variables', + 'attachments', 'cc', 'bcc', 'recipient_type', @@ -38,6 +39,7 @@ class MailCampaign extends Model protected $casts = [ 'variables' => 'array', + 'attachments' => 'array', 'total_recipients' => 'integer', 'sent_count' => 'integer', 'failed_count' => 'integer', diff --git a/app/Repositories/Concerns/FiltersBillingRecords.php b/app/Repositories/Concerns/FiltersBillingRecords.php index a14e177..6d8c704 100644 --- a/app/Repositories/Concerns/FiltersBillingRecords.php +++ b/app/Repositories/Concerns/FiltersBillingRecords.php @@ -63,7 +63,7 @@ trait FiltersBillingRecords } if (!in_array($sortBy, $columns, true)) { - return $query; + return $query->orderByDesc("{$table}.id"); } return $query->orderBy("{$table}.{$sortBy}", $direction)->orderByDesc("{$table}.id"); diff --git a/app/Services/Api/Client/ClientAuthService.php b/app/Services/Api/Client/ClientAuthService.php index 006a90b..6dc26b9 100644 --- a/app/Services/Api/Client/ClientAuthService.php +++ b/app/Services/Api/Client/ClientAuthService.php @@ -105,6 +105,7 @@ class ClientAuthService 'email_verify_code' => $code, 'email_verify_code_expired_at' => now()->addMinutes(5), ]); + $client->forceFill(['two_factor_enabled' => true])->save(); $this->sendVerifyEmail($client, $code); diff --git a/app/Services/IpPool/IpReleaseService.php b/app/Services/IpPool/IpReleaseService.php new file mode 100644 index 0000000..42d1563 --- /dev/null +++ b/app/Services/IpPool/IpReleaseService.php @@ -0,0 +1,75 @@ + $ids + */ + public function release(array $ids, ?string $note, User $admin): int + { + return DB::transaction(function () use ($ids, $note, $admin) { + $pools = IpPool::whereIn('id', $ids)->lockForUpdate()->get(); + $count = 0; + + foreach ($pools as $pool) { + $mirrors = ModuleIpAddress::where('ip', $pool->ip_address)->get(); + $vms = ModuleVmware::where('ip', $pool->ip_address) + ->orWhereIn('module_ip_address', $mirrors->pluck('id')) + ->get(); + + if ($pool->status === 'free' && $pool->service_id === null && $vms->isEmpty()) { + continue; + } + + $before = $pool->only(['status', 'customer_id', 'customer_name', 'service_name', 'service_id']); + $this->detach($pool, $mirrors->pluck('id')->all(), $vms->pluck('id')->all()); + IpPoolLog::record($pool, IpPoolLog::VM_RELEASED, $before['status'], [ + 'customer_id' => $before['customer_id'], + 'customer_name' => $before['customer_name'], + 'service_name' => $before['service_name'], + 'service_id' => $before['service_id'], + 'vm_name' => $vms->pluck('name')->filter()->implode(', ') ?: null, + 'user_id' => $admin->id, + 'note' => $note ?: 'Giải phóng IP khỏi dịch vụ/VM', + ]); + $count++; + } + + return $count; + }); + } + + /** + * @param array $mirrorIds + * @param array $vmIds + */ + private function detach(IpPool $pool, array $mirrorIds, array $vmIds): void + { + $pool->update([ + 'status' => 'free', + 'service_id' => null, + 'customer_id' => null, + 'customer_name' => null, + 'service_name' => null, + 'contact_email' => null, + 'contact_cc' => null, + 'revoked_at' => now()->toDateString(), + ]); + + ModuleIpAddress::whereIn('id', $mirrorIds)->update(['status' => 0, 'vm_name' => null]); + ModuleVmware::whereIn('id', $vmIds)->update(['ip' => null, 'module_ip_address' => null]); + VirtualMachines::where('ip_pool_id', $pool->id) + ->orWhere('ip', $pool->ip_address) + ->update(['ip_pool_id' => null, 'ip' => null]); + } +} diff --git a/app/Services/VirtualMachine/VmCredentialsEmailDataBuilder.php b/app/Services/VirtualMachine/VmCredentialsEmailDataBuilder.php index 85a0ab6..346c68b 100644 --- a/app/Services/VirtualMachine/VmCredentialsEmailDataBuilder.php +++ b/app/Services/VirtualMachine/VmCredentialsEmailDataBuilder.php @@ -45,13 +45,11 @@ class VmCredentialsEmailDataBuilder private function billingLabel(?int $months): string { - return match ($months) { - 1 => 'Hang thang', - 3 => '3 Thang', - 6 => '6 Thang', - 12 => 'Hang nam', - 24 => '2 Nam', - default => ($months ?? 0) . ' Thang', + return match (true) { + $months === 1 => 'Hàng tháng', + $months === 12 => 'Hàng năm', + $months > 12 && $months % 12 === 0 => ($months / 12) . ' năm', + default => ($months ?? 0) . ' tháng', }; } diff --git a/app/Support/ClientPassword.php b/app/Support/ClientPassword.php new file mode 100644 index 0000000..fa9b8e0 --- /dev/null +++ b/app/Support/ClientPassword.php @@ -0,0 +1,20 @@ +boolean('two_factor_enabled')->default(true)->change(); + }); + + DB::table('customers')->where('two_factor_enabled', false)->update(['two_factor_enabled' => true]); + } + + public function down(): void + { + Schema::table('customers', function ($table) { + $table->boolean('two_factor_enabled')->default(false)->change(); + }); + } +}; diff --git a/database/migrations/2026_10_27_000003_add_attachments_to_mail_campaigns_table.php b/database/migrations/2026_10_27_000003_add_attachments_to_mail_campaigns_table.php new file mode 100644 index 0000000..b4f6fda --- /dev/null +++ b/database/migrations/2026_10_27_000003_add_attachments_to_mail_campaigns_table.php @@ -0,0 +1,22 @@ +json('attachments')->nullable()->after('variables'); + }); + } + + public function down(): void + { + Schema::table('mail_campaigns', function (Blueprint $table) { + $table->dropColumn('attachments'); + }); + } +}; diff --git a/routes/api/admin/infrastructure.php b/routes/api/admin/infrastructure.php index bf8df08..f884e7a 100644 --- a/routes/api/admin/infrastructure.php +++ b/routes/api/admin/infrastructure.php @@ -115,6 +115,7 @@ Route::prefix('ip-pool') Route::post('allocate', 'allocate')->middleware('perm:module-vcenter.manager'); Route::post('revocation/preview', 'revocationPreview')->middleware('perm:module-vcenter.manager'); Route::post('revoke', 'revoke')->middleware('perm:module-vcenter.manager'); + Route::post('release', 'release')->middleware('perm:module-vcenter.manager'); Route::get('logs', 'allLogs')->middleware('perm:module-vcenter.view'); Route::get('{id}/logs', 'logs')->middleware('perm:module-vcenter.view'); Route::get('{id}', 'show')->middleware('perm:module-vcenter.view'); diff --git a/routes/api/admin/system.php b/routes/api/admin/system.php index 4759b4d..b359647 100644 --- a/routes/api/admin/system.php +++ b/routes/api/admin/system.php @@ -193,6 +193,7 @@ Route::prefix('mail-campaigns') Route::post('bulk-delete', 'bulkDelete')->middleware('perm:mail-campaign.manager'); Route::get('{uuid}/preview', 'previewCampaign')->middleware('perm:mail-campaign.view'); Route::get('{uuid}/logs/{id}', 'showLog')->whereNumber('id')->middleware('perm:mail-campaign.view'); + Route::get('{uuid}/attachments/{index}', 'downloadAttachment')->whereNumber('index')->middleware('perm:mail-campaign.view'); Route::get('{uuid}', 'show')->middleware('perm:mail-campaign.view'); Route::post('/', 'store')->middleware('perm:mail-campaign.create'); });