feat: nhắc gia hạn QR theo dịch vụ, failover server hosting, CCCD admin, log cấp phát dịch vụ

- Mail campaign: mẫu nhắc gia hạn thanh toán QR, chọn dịch vụ + chu kỳ từng dòng (renewal_services)
- Hosting: nhiều server Plesk cho một gói (service_plan_servers), chuyển server khi server chết
- Admin xem/sửa giấy tờ định danh khách, tuỳ chọn gia hạn cho khách, nhật ký cấp phát dịch vụ
- Đánh dấu mẫu email dịch vụ là non-marketing

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
baodev0
2026-10-30 18:36:41 +07:00
co-authored by Claude Sonnet 5.5
parent 047c23beff
commit 55a834a2f8
53 changed files with 1463 additions and 119 deletions
+57 -61
View File
@@ -7,6 +7,8 @@ namespace App\Jobs\Hosting;
use App\Models\Invoice;
use App\Models\OrderItem;
use App\Models\ServicePlan;
use App\Services\Hosting\HostingProvisionFailure;
use App\Services\Hosting\HostingServerSelector;
use App\Services\Plesk\PleskHostingService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -14,6 +16,8 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Throwable;
use App\Support\ServiceProvisionLog;
/**
* Auto-provisions a Plesk hosting account when a hosting order is paid,
@@ -26,97 +30,89 @@ class ProvisionHostingAccountJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/** Plesk subscription creation can take a couple of minutes */
public int $timeout = 300;
/** No auto-retry — hosting provisioning errors need manual review */
public int $tries = 1;
public function __construct(public OrderItem $item)
{
}
public function handle(PleskHostingService $plesk): void
public function handle(PleskHostingService $plesk, HostingServerSelector $servers, HostingProvisionFailure $failure): void
{
try {
$error = $this->provision($plesk, $servers, $failure);
} catch (Throwable $e) {
$error = $e->getMessage();
}
if ($error !== null) {
$failure->handle($this->item, $error);
}
}
private function provision(PleskHostingService $plesk, HostingServerSelector $servers, HostingProvisionFailure $failure): ?string
{
$service = $this->item->service()->first();
$domainName = $this->item->server_name;
if (! $service || ! $domainName) {
Log::channel('Custom')->warning('ProvisionHostingAccountJob skipped: missing service or domain', [
'order_item_id' => $this->item->id,
]);
return;
return 'Không tìm thấy dịch vụ hoặc tên miền của đơn hàng.';
}
$servicePlanId = $this->item->product?->service_plan_id;
$servicePlan = $servicePlanId
? ServicePlan::with(['plesk', 'dnsServer'])->find($servicePlanId)
: null;
$servicePlan = $servicePlanId ? ServicePlan::with('plesk')->find($servicePlanId) : null;
$hostingPlan = $servicePlan?->plesk?->plesk_plan_name;
$server = $servicePlan?->dnsServer;
Log::channel('Custom')->info('ProvisionHostingAccountJob resolved service plan', [
'order_item_id' => $this->item->id,
'product_id' => $this->item->product_id,
'product_name' => $this->item->product?->name,
'service_plan_id' => $servicePlanId,
'plesk_plan_name' => $hostingPlan,
'dns_manager_server_id' => $server?->id,
'dns_manager_server_ip' => $server?->ip_address,
'order_item_id' => $this->item->id,
'product_id' => $this->item->product_id,
'product_name' => $this->item->product?->name,
'service_plan_id' => $servicePlanId,
'plesk_plan_name' => $hostingPlan,
]);
if (! $hostingPlan) {
Log::channel('Custom')->error('ProvisionHostingAccountJob failed: no service_plan_plesk mapping for this product', [
'order_item_id' => $this->item->id,
'product_id' => $this->item->product_id,
'service_plan_id' => $servicePlanId,
]);
return;
return 'Sản phẩm chưa gắn gói Plesk (service_plan_plesk).';
}
if (! $server) {
Log::channel('Custom')->error('ProvisionHostingAccountJob failed: service plan has no dns_manager_server_id configured', [
'order_item_id' => $this->item->id,
'product_id' => $this->item->product_id,
'service_plan_id' => $servicePlanId,
]);
ServiceProvisionLog::record($service, 'provision_started', 'Bắt đầu khởi tạo hosting', ['domain' => $domainName, 'plesk_plan' => $hostingPlan]);
return;
}
$invoiceId = Invoice::where('order_id', $this->item->order_id)->where('invoice_type', 1)->orderBy('id')->value('id');
$triedServerIds = [];
$lastError = null;
if (! $server->servesHosting()) {
Log::channel('Custom')->error('ProvisionHostingAccountJob failed: configured server does not serve hosting', [
'order_item_id' => $this->item->id,
'service_plan_id' => $servicePlanId,
while ($server = $servers->pick($servicePlan, $triedServerIds)) {
$triedServerIds[] = $server->id;
$result = $plesk->provision([
'customer_id' => $this->item->order->customer_id,
'service_id' => $service->id,
'dns_manager_server_id' => $server->id,
'server_type' => $server->type,
'domain_name' => $domainName,
'hosting_plan' => $hostingPlan,
'invoice_id' => $invoiceId,
]);
return;
if (! $result['error']) {
$service->update(['status' => 'active']);
ServiceProvisionLog::record($service, 'provision_completed', 'Khởi tạo hoàn tất — dịch vụ đã hoạt động', ['server_id' => $server->id]);
return null;
}
$lastError = $result['message'] ?? 'Plesk trả về lỗi không rõ.';
$servers->forget($server);
if ($servers->isHealthy($server)) {
return $lastError;
}
$failure->serverFailedOver($this->item, $server, $lastError);
}
$result = $plesk->provision([
'customer_id' => $this->item->order->customer_id,
'service_id' => $service->id,
'dns_manager_server_id' => $server->id,
'domain_name' => $domainName,
'hosting_plan' => $hostingPlan,
'invoice_id' => Invoice::where('order_id', $this->item->order_id)->where('invoice_type', 1)->orderBy('id')->value('id'),
]);
if ($result['error']) {
Log::channel('Custom')->error('ProvisionHostingAccountJob failed', [
'order_item_id' => $this->item->id,
'hosting_plan' => $hostingPlan,
'message' => $result['message'] ?? 'Unknown error',
]);
return;
}
$service->update(['status' => 'active']);
return $lastError === null
? 'Gói hosting không có server Plesk nào đang hoạt động.'
: "Tất cả server Plesk của gói đều lỗi. Lỗi cuối: {$lastError}";
}
}