Files
portal-api/app/Services/IpPool/IpAllocationService.php
T
baodev0 0b08689ea4 feat: harden admin auth and ticket workflows
- add rotating admin refresh tokens and password gates\n- expand ticket galleries, exports, and client resources\n- improve IP allocation auditing and dashboard data\n- enforce encrypted client access tokens on public routes
2026-10-16 17:00:55 +07:00

299 lines
12 KiB
PHP

<?php
namespace App\Services\IpPool;
use App\Mail\IpAllocationMail;
use App\Models\Customer;
use App\Models\IpPool;
use App\Models\IpPoolLog;
use App\Models\Mail as MailLog;
use App\Models\ModuleIpAddress;
use App\Models\User;
use App\Services\Email\EmailTemplateService;
use App\Support\AutoEmailGate;
use App\Support\EmailRecipients;
use App\Support\MailAttachments;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Validation\ValidationException;
class IpAllocationService
{
public function __construct(private readonly EmailTemplateService $templates) {}
/**
* @param array<int, int> $ids
*/
public function previewAllocation(array $ids, Customer $customer, array $data): array
{
$pools = $this->allocatable($this->pools($ids));
$pools->each(fn (IpPool $pool) => $pool->service_name = $this->serviceNameFor($pool, $data));
return $this->preview($this->allocationMail($customer, $pools, $data));
}
/**
* @param array<int, int> $ids
*/
public function allocate(array $ids, Customer $customer, array $data, User $admin): int
{
$this->ensureMailReady(IpAllocationMail::ALLOCATED);
$files = MailAttachments::store($data['attachments'] ?? [], "customer-emails/{$customer->id}");
return DB::transaction(function () use ($ids, $customer, $data, $admin, $files) {
$pools = $this->allocatable($this->pools($ids, lock: true));
$copies = $this->emailCopies(IpAllocationMail::ALLOCATED, $data['cc'] ?? null);
foreach ($pools as $pool) {
$from = $pool->status;
$pool->update([
'status' => 'assigned',
'customer_id' => $customer->id,
'customer_name' => $customer->fullname,
'service_name' => $this->serviceNameFor($pool, $data),
'allocated_at' => $data['date'],
'revoked_at' => null,
'contact_email' => $data['to'],
'contact_cc' => $data['cc'] ?? null,
'last_email_type' => 'ALLOCATED_SENT',
'last_email_at' => now(),
'updated_by' => $admin->username,
]);
IpPoolLog::record($pool, IpPoolLog::ALLOCATED, $from, [
'effective_date' => $data['date'],
'customer_id' => $customer->id,
'customer_name' => $customer->fullname,
'service_name' => $pool->service_name,
'email_to' => $data['to'],
...$copies,
'note' => $data['note'] ?? null,
]);
}
$this->send($this->allocationMail($customer, $pools, $data, $files), $data['to'], $data['cc'] ?? null, $customer->id, $admin);
return $pools->count();
});
}
/**
* @param array<int, int> $ids
* @return array<int, array<string, mixed>>
*/
public function previewRevocation(array $ids, array $data): array
{
return $this->revocationGroups($this->revocable($this->pools($ids)))
->map(fn (Collection $pools, string $key) => [
'key' => $key,
'customer_id' => $pools->first()->customer_id,
'customer_name' => $pools->first()->customer_name,
'to' => $pools->first()->contact_email,
'cc' => $pools->first()->contact_cc,
'ips' => $pools->pluck('ip_address')->values(),
...$this->preview($this->revocationMail($pools, $data)),
])
->values()
->all();
}
/**
* @param array<int, int> $ids
* @param array<string, array{to: string, cc?: string|null}> $recipients
*/
public function revoke(array $ids, array $data, array $recipients, User $admin): int
{
$this->ensureMailReady(IpAllocationMail::REVOKED);
$groups = $this->revocationGroups($this->revocable($this->pools($ids)));
$missing = $groups->keys()->reject(fn ($key) => filled($recipients[$key]['to'] ?? null));
if ($missing->isNotEmpty()) {
throw ValidationException::withMessages(['recipients' => 'Chưa nhập email người nhận cho: ' . $groups->only($missing->all())
->map(fn ($pools) => $pools->first()->customer_name ?: $pools->pluck('ip_address')->implode(', '))->implode('; ')]);
}
return DB::transaction(function () use ($ids, $data, $recipients, $admin) {
$groups = $this->revocationGroups($this->revocable($this->pools($ids, lock: true)));
return $groups->map(function (Collection $pools, string $key) use ($data, $recipients, $admin) {
$copies = $this->emailCopies(IpAllocationMail::REVOKED, $recipients[$key]['cc'] ?? null);
$mail = $this->revocationMail($pools, $data, $recipients[$key]['subject'] ?? null);
$customerId = $pools->first()->customer_id;
foreach ($pools as $pool) {
$from = $pool->status;
$before = $pool->only(['customer_id', 'customer_name', 'service_name']);
$pool->update([
'status' => 'free',
'customer_id' => null,
'customer_name' => null,
'service_name' => null,
'contact_email' => null,
'revoked_at' => $data['date'],
'last_email_type' => 'REVOKE_SENT',
'last_email_at' => now(),
'updated_by' => $admin->username,
]);
IpPoolLog::record($pool, IpPoolLog::REVOKED, $from, [
...$before,
'effective_date' => $data['date'],
'email_to' => $recipients[$key]['to'],
...$copies,
'note' => $data['note'] ?? null,
]);
}
$this->send($mail, $recipients[$key]['to'], $recipients[$key]['cc'] ?? null, $customerId, $admin);
return $pools->count();
})->sum();
});
}
/**
* @param array<int, int> $ids
* @return Collection<int, IpPool>
*/
private function pools(array $ids, bool $lock = false): Collection
{
$query = IpPool::whereIn('id', $ids)->orderBy('ip_address');
$pools = ($lock ? $query->lockForUpdate() : $query)->get();
if ($pools->isEmpty()) {
throw ValidationException::withMessages(['ids' => 'Không tìm thấy IP nào.']);
}
return $pools;
}
private function allocatable(Collection $pools): Collection
{
$blocked = $pools->reject(fn (IpPool $pool) => $pool->status === 'free');
if ($blocked->isNotEmpty()) {
throw ValidationException::withMessages(['ids' => 'Chỉ cấp phát được IP đang trống. IP không hợp lệ: ' . $blocked->pluck('ip_address')->implode(', ')]);
}
return $pools;
}
private function revocable(Collection $pools): Collection
{
$inUse = ModuleIpAddress::where('status', 1)->whereIn('ip', $pools->pluck('ip_address'))->pluck('ip')->all();
$blocked = $pools->filter(fn (IpPool $pool) => $pool->status !== 'assigned'
|| $pool->service_id !== null
|| in_array($pool->ip_address, $inUse, true));
if ($blocked->isNotEmpty()) {
throw ValidationException::withMessages(['ids' => 'Chỉ thu hồi được IP đã cấp và không gắn VM/dịch vụ (IP gắn VM thu hồi qua dịch vụ). IP không hợp lệ: ' . $blocked->pluck('ip_address')->implode(', ')]);
}
return $pools;
}
/**
* @return \Illuminate\Support\Collection<string, Collection<int, IpPool>>
*/
private function revocationGroups(Collection $pools): \Illuminate\Support\Collection
{
return $pools->groupBy(fn (IpPool $pool) => $pool->customer_id
? 'customer:' . $pool->customer_id
: 'name:' . mb_strtolower(trim((string) $pool->customer_name)) . '|' . mb_strtolower(trim((string) $pool->contact_email)))
->toBase();
}
private function allocationMail(Customer $customer, Collection $pools, array $data, array $files = []): IpAllocationMail
{
return new IpAllocationMail(
IpAllocationMail::ALLOCATED,
$customer->fullname,
IpAllocationMail::ipsFrom($pools),
$data['date'],
$data['note'] ?? null,
$data['subject'] ?? null,
$files,
);
}
private function serviceNameFor(IpPool $pool, array $data): ?string
{
$name = trim((string) (($data['services'][$pool->id] ?? null) ?: ($data['service_name'] ?? '')));
return $name === '' ? null : $name;
}
private function revocationMail(Collection $pools, array $data, ?string $subject = null): IpAllocationMail
{
return new IpAllocationMail(
IpAllocationMail::REVOKED,
$pools->first()->customer_name ?: 'Quý khách',
IpAllocationMail::ipsFrom($pools),
$data['date'],
$data['note'] ?? null,
$subject,
);
}
/**
* @return array{subject: string, html: string}
*/
private function preview(IpAllocationMail $mail): array
{
try {
return ['subject' => $mail->envelope()->subject, 'html' => $mail->render()];
} catch (ModelNotFoundException) {
throw ValidationException::withMessages(['template' => 'Chưa có mẫu email đang bật cho thao tác này (Mẫu email → ' . $mail->type . ').']);
}
}
private function ensureMailReady(string $scenario): void
{
if (!AutoEmailGate::mailEnabled()) {
throw ValidationException::withMessages(['mail' => 'Hệ thống đang tắt gửi email (Mẫu email → Cài đặt → Bật gửi email).']);
}
try {
$this->templates->loadTemplate($scenario);
} catch (ModelNotFoundException) {
throw ValidationException::withMessages(['template' => "Chưa có mẫu email {$scenario} đang bật trong Mẫu email."]);
}
}
/**
* @return array{email_cc: string|null, email_bcc: string|null}
*/
private function emailCopies(string $scenario, ?string $cc): array
{
$config = $this->templates->loadTemplate($scenario)->mailConfig();
$join = fn (array $emails) => $emails ? implode(', ', array_values(array_unique($emails))) : null;
return [
'email_cc' => $join([...EmailRecipients::parse($cc), ...($config['cc'] ?? [])]),
'email_bcc' => $join($config['bcc'] ?? []),
];
}
private function send(IpAllocationMail $mail, string $to, ?string $cc, ?int $customerId, User $admin): void
{
AutoEmailGate::markManual();
$template = $this->templates->loadTemplate($mail->type);
$toList = EmailRecipients::parse($to);
$ccList = EmailRecipients::parse($cc);
$copies = $this->emailCopies($mail->type, $cc);
MailLog::create([
'template_id' => $template->id,
'subject' => $mail->envelope()->subject,
'from_address' => $template->mailConfig()['from_email'] ?? config('mail.from.address'),
'to' => implode(', ', $toList),
'cc' => $copies['email_cc'],
'bcc' => $copies['email_bcc'],
'customer_id' => $customerId,
'sent_by' => $admin->id,
'message' => (clone $mail)->render(),
]);
Mail::to($toList)->cc($ccList)->queue($mail);
}
}