feat(service): huỷ dịch vụ, SSL, đổi mật khẩu hosting, lịch sử thanh toán theo hình thức

- Lịch sử thanh toán hoá đơn trả thêm `method` (tự động gia hạn / số dư / admin / chuyển khoản / hoàn tiền), bỏ đuôi `(method)` khỏi ghi chú
- `DnsServer::panelUrl()` = https://<domain>/ dùng cho email cấp hosting và nút Plesk của khách
- Order item trả thêm `uuid`/`type` của dịch vụ để FE dẫn tới trang quản lý
- Yêu cầu huỷ dịch vụ, chứng chỉ SSL, lịch sử gia hạn, đổi mật khẩu hosting cho khách

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
baodev0
2026-10-28 16:40:24 +07:00
co-authored by Claude Sonnet 5.5
parent a6b641015e
commit b44a88bb52
72 changed files with 1701 additions and 231 deletions
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers\Api\Client\Service;
use App\Http\Controllers\Controller;
use App\Models\ModuleHostingPleskClient;
use App\Models\Service;
use App\Services\Plesk\PleskClientService;
use App\Support\ClientPassword;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class HostingPasswordController extends Controller
{
public function __invoke(Request $request, string $uuid, PleskClientService $plesk): JsonResponse
{
$data = $request->validate(
['password' => ['required', 'string', 'max:64', 'confirmed', ClientPassword::rule()]],
['password.min' => ClientPassword::minMessage()],
);
$hosting = Service::with('hostingAccount.server')
->where('uuid', $uuid)
->where('customer_id', $request->user()->id)
->where('status', 'active')
->first()?->hostingAccount;
$client = $hosting ? ModuleHostingPleskClient::forHosting($hosting) : null;
if (!$client || !$hosting->server) {
return response()->json(['error' => true, 'message' => 'Không tìm thấy dịch vụ hosting'], 404);
}
if (!$plesk->changePassword($hosting->server, $client, $data['password'])) {
return response()->json(['error' => true, 'message' => 'Plesk từ chối mật khẩu mới. Hãy dùng mật khẩu mạnh hơn (chữ hoa, chữ thường, số, ký tự đặc biệt).'], 422);
}
return response()->json(['error' => false, 'message' => 'Đã đổi mật khẩu Plesk']);
}
}
@@ -0,0 +1,68 @@
<?php
namespace App\Http\Controllers\Api\Client\Service;
use App\Http\Controllers\Controller;
use App\Models\Service;
use App\Models\ServiceCancellationRequest;
use App\Services\ServiceCancellationNotifier;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ServiceCancellationController extends Controller
{
private const CLOSED_STATUSES = ['cancelled', 'terminated'];
public function __construct(private readonly ServiceCancellationNotifier $notifier) {}
public function store(Request $request, string $uuid): JsonResponse
{
$service = $this->ownedService($request, $uuid);
$data = $request->validate([
'reason_code' => 'nullable|string|max:40',
'reason' => 'required|string|min:3|max:1000',
]);
if (in_array($service->status, self::CLOSED_STATUSES, true)) {
return response()->json(['error' => true, 'message' => 'Dịch vụ đã được hủy.'], 422);
}
if ($service->cancel_at_expiry) {
return response()->json(['error' => true, 'message' => 'Dịch vụ đã được lên lịch hủy khi hết hạn.'], 422);
}
if ($service->cancellationRequests()->pending()->exists()) {
return response()->json(['error' => true, 'message' => 'Bạn đã gửi yêu cầu hủy dịch vụ này, vui lòng chờ nhân viên xử lý.'], 422);
}
$cancellation = $service->cancellationRequests()->create([
'customer_id' => $service->customer_id,
'reason_code' => $data['reason_code'] ?? null,
'reason' => trim($data['reason']),
]);
$this->notifier->notify($cancellation, $request->ip());
return response()->json(['error' => false, 'message' => 'Đã gửi yêu cầu hủy dịch vụ.', 'data' => $this->payload($cancellation)], 201);
}
public function destroy(Request $request, string $uuid): JsonResponse
{
$this->ownedService($request, $uuid)->cancellationRequests()->pending()->delete();
return response()->json(['error' => false, 'message' => 'Đã rút yêu cầu hủy dịch vụ.']);
}
public static function payload(?ServiceCancellationRequest $request): ?array
{
return $request ? [
'reason' => $request->reason,
'requested_at' => $request->created_at?->toISOString(),
] : null;
}
private function ownedService(Request $request, string $uuid): Service
{
return Service::where('uuid', $uuid)->where('customer_id', $request->user()->id)->firstOrFail();
}
}
@@ -84,6 +84,21 @@ class ServiceController extends Controller
->response();
}
public function indexGroup(Request $request, string $group): JsonResponse
{
$perPage = min((int) $request->get('per_page', 15), 100);
$services = $this->serviceClientService->getActiveServices($request->user()->id, null, $perPage, $group);
return (new VpsServiceCollection($services))
->additional([
'error' => false,
'message' => 'Lấy dữ liệu thành công',
'page_type' => 'service_' . $group,
])
->response();
}
public function indexDomain(Request $request): JsonResponse
{
$customerId = $request->user()->id;
@@ -102,11 +117,12 @@ class ServiceController extends Controller
->where('customer_id', $customerId)
->where('type', 'domain')
->orderByDesc('id')
->get(['uuid', 'server_name', 'status', 'started_at', 'expires_at'])
->get(['uuid', 'server_name', 'status', 'started_at', 'expires_at', 'cancel_at_expiry'])
->map(fn (Service $service) => [
'domain_name' => $service->server_name,
'service_uuid' => $service->uuid,
'status' => $service->status,
'cancel_at_expiry' => (bool) $service->cancel_at_expiry,
'started_at' => $service->started_at?->toISOString(),
'expires_at' => $service->expires_at?->toISOString(),
...$zoneFields($zones->pull(mb_strtolower(trim((string) $service->server_name)))),
@@ -194,20 +210,16 @@ class ServiceController extends Controller
]);
}
public function getVmPassword(Request $request, string $uuid)
public function downloadAttachment(Request $request, string $uuid, int $attachmentId)
{
$service = Service::with('vmware')
->where('uuid', $uuid)
->where('customer_id', $request->user()->id)
->first();
$attachment = \App\Models\ServiceAttachment::forCustomer()
->whereKey($attachmentId)
->whereHas('service', fn ($query) => $query
->where('uuid', $uuid)
->where('customer_id', $request->user()->id))
->firstOrFail();
$vm = $service?->vmware;
if (!$vm) {
return response()->json(['error' => true, 'message' => 'Không tìm thấy dịch vụ'], 404);
}
return response()->json(['error' => false, 'data' => ['password' => $vm->plain_password]]);
return \Illuminate\Support\Facades\Storage::disk($attachment->disk)->response($attachment->path, $attachment->filename);
}
public function getConsoleTicket(Request $request, string $uuid, vCenter $serviceVCenter)
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers\Api\Client\Service;
use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Models\Service;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ServiceRenewalHistoryController extends Controller
{
private const CAUSER_LABELS = [
'customer' => 'Bạn',
'admin' => 'Nhân viên hỗ trợ',
'system' => 'Tự động gia hạn',
];
public function __invoke(Request $request, string $uuid): JsonResponse
{
$service = Service::where('uuid', $uuid)->where('customer_id', $request->user()->id)->firstOrFail();
$items = ActivityLog::where('subject_type', Service::class)
->where('subject_id', $service->id)
->where('event', 'renewed')
->latest('id')
->limit(100)
->get(['causer_type', 'old_values', 'new_values', 'created_at'])
->map(fn (ActivityLog $log) => [
'renewed_at' => $log->created_at?->toISOString(),
'months' => (int) ($log->new_values['months'] ?? 0),
'amount' => (int) ($log->new_values['amount'] ?? 0),
'invoice_number' => $log->new_values['invoice_number'] ?? null,
'paid_by' => ($log->new_values['payment_method'] ?? null) === 'balance' ? 'Số dư tài khoản' : 'Thanh toán khác',
'old_expires_at' => $log->old_values['expires_at'] ?? null,
'new_expires_at' => $log->new_values['expires_at'] ?? null,
'by' => self::CAUSER_LABELS[$log->causer_type] ?? self::CAUSER_LABELS['system'],
])
->values();
return response()->json(['error' => false, 'data' => $items]);
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers\Api\Client\Service;
use App\Http\Controllers\Controller;
use App\Models\ServiceSslCertificate;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use ZipArchive;
class SslArchiveController extends Controller
{
public function __invoke(Request $request, string $uuid)
{
$certificate = ServiceSslCertificate::query()
->whereHas('service', fn ($query) => $query
->where('uuid', $uuid)
->where('customer_id', $request->user()->id))
->with('service:id,uuid,server_name')
->firstOrFail();
$path = tempnam(sys_get_temp_dir(), 'ssl');
$zip = new ZipArchive();
$zip->open($path, ZipArchive::OVERWRITE);
foreach ($certificate->data['files'] as $file) {
$zip->addFromString($file['name'], $file['content']);
}
if ($certificate->data['fields'] !== []) {
$zip->addFromString('thong-tin.txt', collect($certificate->data['fields'])
->map(fn ($field) => "{$field['label']}: {$field['value']}")
->implode("\n\n"));
}
$zip->close();
$name = Str::slug($certificate->service->server_name ?: 'ssl') . '-ssl.zip';
return response()->download($path, $name)->deleteFileAfterSend();
}
}