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:
co-authored by
Claude Sonnet 5.5
parent
a6b641015e
commit
b44a88bb52
@@ -30,7 +30,7 @@ class ProductController extends Controller
|
||||
{
|
||||
$perPage = min((int) $request->get('per_page', 15), 200);
|
||||
$paginator = $this->products->paginate(
|
||||
$request->only('filter', 'type', 'is_active'),
|
||||
$request->only('filter', 'type', 'is_active', 'product_categories_id'),
|
||||
$perPage
|
||||
);
|
||||
|
||||
|
||||
@@ -28,11 +28,15 @@ trait HandlesServiceAttachments
|
||||
public function uploadAttachment(Request $request, int $id): JsonResponse
|
||||
{
|
||||
Service::findOrFail($id);
|
||||
$request->validate(['file' => ServiceAttachment::UPLOAD_RULE]);
|
||||
$audience = $request->validate([
|
||||
'file' => ServiceAttachment::UPLOAD_RULE,
|
||||
'audience' => 'nullable|in:' . ServiceAttachment::AUDIENCE_ADMIN . ',' . ServiceAttachment::AUDIENCE_CUSTOMER,
|
||||
])['audience'] ?? ServiceAttachment::AUDIENCE_ADMIN;
|
||||
|
||||
$file = $request->file('file');
|
||||
$attachment = ServiceAttachment::create([
|
||||
'service_id' => $id,
|
||||
'audience' => $audience,
|
||||
'filename' => $file->getClientOriginalName(),
|
||||
'path' => $file->store("service-attachments/{$id}", 'public'),
|
||||
'disk' => 'public',
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\Service;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Notification;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceCancellationRequest;
|
||||
use App\Support\ServiceCancellation;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ServiceCancellationRequestController extends Controller
|
||||
{
|
||||
public function index(int $id): JsonResponse
|
||||
{
|
||||
$requests = ServiceCancellationRequest::where('service_id', $id)
|
||||
->latest('id')
|
||||
->limit(20)
|
||||
->get(['id', 'reason_code', 'reason', 'status', 'admin_note', 'resolved_at', 'resolved_by', 'created_at']);
|
||||
|
||||
return response()->json(['error' => false, 'data' => $requests]);
|
||||
}
|
||||
|
||||
public function resolve(Request $request, int $id, int $requestId): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'action' => 'required|in:approve,reject',
|
||||
'note' => 'required_if:action,reject|nullable|string|max:1000',
|
||||
]);
|
||||
$approve = $data['action'] === 'approve';
|
||||
|
||||
$result = DB::transaction(function () use ($id, $requestId, $data, $approve, $request) {
|
||||
$cancellation = ServiceCancellationRequest::where('service_id', $id)->lockForUpdate()->findOrFail($requestId);
|
||||
if ($cancellation->status !== ServiceCancellationRequest::STATUS_PENDING) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$admin = $request->user();
|
||||
$cancellation->update([
|
||||
'status' => $approve ? ServiceCancellationRequest::STATUS_APPROVED : ServiceCancellationRequest::STATUS_REJECTED,
|
||||
'admin_note' => filled($data['note'] ?? null) ? trim($data['note']) : null,
|
||||
'resolved_at' => now(),
|
||||
'resolved_by' => (string) ($admin?->fullname ?: $admin?->username ?: 'admin'),
|
||||
]);
|
||||
|
||||
$outcome = $approve ? ServiceCancellation::approve(Service::with('orderItem')->findOrFail($id)) : null;
|
||||
|
||||
return [$cancellation, $outcome];
|
||||
});
|
||||
|
||||
[$result, $outcome] = $result ?? [null, null];
|
||||
|
||||
if ($result === null) {
|
||||
return response()->json(['error' => true, 'message' => 'Yêu cầu này đã được xử lý.'], 422);
|
||||
}
|
||||
|
||||
$this->notifyCustomer($result, $approve, $outcome);
|
||||
|
||||
$message = match (true) {
|
||||
! $approve => 'Đã từ chối yêu cầu hủy.',
|
||||
$outcome === ServiceCancellation::SCHEDULED => 'Đã duyệt: dịch vụ dùng tới hết hạn rồi tự hủy.',
|
||||
default => 'Đã duyệt và hủy dịch vụ.',
|
||||
};
|
||||
|
||||
return response()->json(['error' => false, 'message' => $message, 'data' => $result]);
|
||||
}
|
||||
|
||||
private function notifyCustomer(ServiceCancellationRequest $cancellation, bool $approve, ?string $outcome): void
|
||||
{
|
||||
$cancellation->loadMissing(['customer', 'service']);
|
||||
if (! $cancellation->customer) {
|
||||
return;
|
||||
}
|
||||
|
||||
$name = $cancellation->service->server_name ?: 'dịch vụ của bạn';
|
||||
$expiresAt = $cancellation->service->expires_at?->format('d/m/Y');
|
||||
$approvedBody = $outcome === ServiceCancellation::SCHEDULED
|
||||
? "Dịch vụ {$name} sẽ không gia hạn nữa và vẫn sử dụng được đến hết {$expiresAt}."
|
||||
: "Dịch vụ {$name} đã được hủy theo yêu cầu của bạn.";
|
||||
$reason = $cancellation->admin_note ? " Ghi chú: {$cancellation->admin_note}" : '';
|
||||
|
||||
Notification::notifyCustomer($cancellation->customer, [
|
||||
'type' => 'service_cancellation_resolved',
|
||||
'title' => $approve ? 'Yêu cầu hủy dịch vụ đã được duyệt' : 'Yêu cầu hủy dịch vụ bị từ chối',
|
||||
'body' => ($approve ? $approvedBody : "Yêu cầu hủy dịch vụ {$name} chưa được chấp nhận.") . $reason,
|
||||
'link' => '/dich-vu',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ class ServiceController extends Controller
|
||||
$perPage = min((int) ($request->per_page ?? 15), 100);
|
||||
|
||||
$query = Service::with(['customer:id,uuid,fullname,email', 'product:id,uuid,name,product_type', 'invoice:id,invoice_number', 'vmware:id,name,module_ip_address', 'orderItem:id,order_id', 'orderItem.order:id,order_num', 'tags'])
|
||||
->withExists(['cancellationRequests as pending_cancellation' => fn ($q) => $q->pending()])
|
||||
->marked($request->all())
|
||||
->where(fn($q) => $q->whereNull('order_item_id')->orWhereNull('type')->orWhere('type', '!=', 'domain'))
|
||||
->when($request->filter, fn($q, $v) => $q->where('server_name', 'like', "%{$v}%"))
|
||||
@@ -93,6 +94,7 @@ class ServiceController extends Controller
|
||||
->findOrFail($id);
|
||||
|
||||
ModuleVmware::resolveVirtualMachineIds([$service->vmware]);
|
||||
\App\Support\HostingCredentials::attach([$service]);
|
||||
|
||||
$invoices = $this->linkedInvoices(collect([$service]));
|
||||
$payments = InvoicePaymentHistory::forInvoices($invoices);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\Service;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceSslCertificate;
|
||||
use App\Support\ServiceGroup;
|
||||
use App\Support\SslBundleExtractor;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class SslCertificateController extends Controller
|
||||
{
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
return $this->respond($this->sslService($id)->sslCertificate?->data);
|
||||
}
|
||||
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$service = $this->sslService($id);
|
||||
$max = ServiceSslCertificate::MAX_ENTRIES;
|
||||
|
||||
$data = $request->validate([
|
||||
'fields' => "present|array|max:{$max}",
|
||||
'fields.*.label' => 'required|string|max:100',
|
||||
'fields.*.value' => 'nullable|string|max:5000',
|
||||
'files' => "present|array|max:{$max}",
|
||||
'files.*.name' => 'required|string|max:150',
|
||||
'files.*.content' => 'required|string|max:' . ServiceSslCertificate::MAX_FILE_BYTES,
|
||||
]);
|
||||
|
||||
return $this->respond($this->store($service, [
|
||||
'fields' => array_map(fn ($field) => ['label' => $field['label'], 'value' => (string) ($field['value'] ?? '')], $data['fields']),
|
||||
'files' => array_map(fn ($file) => ['name' => basename($file['name']), 'content' => $file['content']], $data['files']),
|
||||
]));
|
||||
}
|
||||
|
||||
public function uploadArchive(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$service = $this->sslService($id);
|
||||
$request->validate(['archive' => 'required|file|mimes:zip|max:10240']);
|
||||
|
||||
try {
|
||||
$extracted = SslBundleExtractor::extract($request->file('archive'));
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
return response()->json(['error' => true, 'message' => $exception->getMessage()], 422);
|
||||
}
|
||||
|
||||
$current = $service->sslCertificate?->data ?? ServiceSslCertificate::emptyData();
|
||||
$byName = collect($current['files'])->keyBy('name');
|
||||
foreach ($extracted as $file) {
|
||||
$byName[$file['name']] = $file;
|
||||
}
|
||||
|
||||
if ($byName->count() > ServiceSslCertificate::MAX_ENTRIES) {
|
||||
return response()->json(['error' => true, 'message' => 'Quá nhiều tệp chứng chỉ, hãy xoá bớt trước khi tải thêm.'], 422);
|
||||
}
|
||||
|
||||
return $this->respond($this->store($service, ['fields' => $current['fields'], 'files' => $byName->values()->all()]));
|
||||
}
|
||||
|
||||
private function sslService(int $id): Service
|
||||
{
|
||||
$service = Service::with(['product:id,name,product_type', 'sslCertificate'])->findOrFail($id);
|
||||
abort_unless(ServiceGroup::of($service->product?->product_type ?? $service->type, $service->product?->name) === ServiceGroup::SSL, 422, 'Dịch vụ này không phải SSL.');
|
||||
|
||||
return $service;
|
||||
}
|
||||
|
||||
private function store(Service $service, array $data): array
|
||||
{
|
||||
ServiceSslCertificate::updateOrCreate(['service_id' => $service->id], ['data' => $data]);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function respond(?array $data): JsonResponse
|
||||
{
|
||||
return response()->json(['error' => false, 'data' => $data ?? ServiceSslCertificate::emptyData()]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ class Kernel extends HttpKernel
|
||||
\App\Http\Middleware\TrustProxies::class,
|
||||
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
|
||||
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
|
||||
\App\Http\Middleware\SealResponse::class,
|
||||
\App\Http\Middleware\TrimStrings::class,
|
||||
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
|
||||
\Illuminate\Http\Middleware\HandleCors::class
|
||||
@@ -89,7 +88,6 @@ class Kernel extends HttpKernel
|
||||
'perm' => \App\Http\Middleware\CheckPermission::class,
|
||||
'restrict.local' => \App\Http\Middleware\RestrictToLocalEnvironment::class,
|
||||
'portal-monitor.service' => \App\Http\Middleware\AuthenticatePortalMonitor::class,
|
||||
'seal.response' => \App\Http\Middleware\SealResponse::class,
|
||||
'turnstile' => \App\Http\Middleware\VerifyTurnstile::class,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Support\ResponseCipher;
|
||||
use Closure;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SealResponse
|
||||
{
|
||||
public const KEY_HEADER = 'x-seal-key';
|
||||
|
||||
public const OPT_IN_HEADER = 'x-seal';
|
||||
|
||||
private const SEALED_ATTRIBUTE = 'response_sealed';
|
||||
|
||||
public function handle(Request $request, Closure $next, ?string $mode = null)
|
||||
{
|
||||
$key = ResponseCipher::keyFrom($request->headers->get(self::KEY_HEADER));
|
||||
|
||||
if ($this->openRequest($request, $key) === false) {
|
||||
return new JsonResponse(['message' => 'Khoá phiên không khớp, vui lòng thử lại.'], 428);
|
||||
}
|
||||
|
||||
$response = $next($request);
|
||||
|
||||
$wanted = $mode === 'always' || $request->headers->get(self::OPT_IN_HEADER) === '1';
|
||||
if (! $key || ! $wanted || $request->attributes->has(self::SEALED_ATTRIBUTE) || ! $response instanceof JsonResponse) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$request->attributes->set(self::SEALED_ATTRIBUTE, true);
|
||||
$response->setData(['enc' => ResponseCipher::seal($response->getContent(), $key)]);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function openRequest(Request $request, ?string $key): ?bool
|
||||
{
|
||||
$sealed = $request->isJson() ? $request->json('enc') : null;
|
||||
if (! is_string($sealed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $key ? json_decode((string) ResponseCipher::open($sealed, $key), true) : null;
|
||||
if (! is_array($data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$request->json()->replace($data);
|
||||
$request->request->replace($data);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -3,31 +3,34 @@
|
||||
namespace App\Http\Resources\Api\Client\Service;
|
||||
|
||||
use App\Models\DnsDomain;
|
||||
use App\Support\ResponseCipher;
|
||||
use App\Support\ServiceGroup;
|
||||
use App\Models\ModuleHostingPleskClient;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
|
||||
class ServiceClientDetailResource extends JsonResource
|
||||
{
|
||||
public const MASKED_PASSWORD = '*****';
|
||||
private const IRRELEVANT_KEYS = [
|
||||
'hosting' => ['os', 'specs', 'vm', 'custom_product', 'ssl_certificate', 'dns_domain_id', 'dns_cancelled'],
|
||||
'vps' => ['hosting', 'service_plan', 'ssl_certificate', 'dns_domain_id', 'dns_cancelled'],
|
||||
'cloud' => ['hosting', 'service_plan', 'ssl_certificate', 'dns_domain_id', 'dns_cancelled'],
|
||||
'domain' => ['os', 'specs', 'vm', 'hosting', 'service_plan', 'custom_product', 'ssl_certificate'],
|
||||
'ssl' => ['os', 'specs', 'vm', 'hosting', 'service_plan', 'custom_product', 'dns_domain_id', 'dns_cancelled'],
|
||||
];
|
||||
|
||||
/** Plesk panel login (persisted, reusable) — separate from the one-time FTP password, which is never stored. */
|
||||
private function resolvePleskCredentials($hosting): ?array
|
||||
{
|
||||
$client = ModuleHostingPleskClient::query()
|
||||
->where('customer_id', $hosting->customer_id)
|
||||
->where('dns_manager_server_id', $hosting->dns_manager_server_id)
|
||||
->first();
|
||||
$client = ModuleHostingPleskClient::forHosting($hosting);
|
||||
|
||||
if (!$client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'login' => $client->login,
|
||||
'password' => Crypt::decryptString($client->password),
|
||||
];
|
||||
return ['login' => $client->login, 'password' => Crypt::decryptString($client->password)];
|
||||
}
|
||||
|
||||
private function resolveDnsZone(): ?DnsDomain
|
||||
@@ -101,9 +104,10 @@ class ServiceClientDetailResource extends JsonResource
|
||||
$dnsZone = $this->resolveDnsZone();
|
||||
$dnsCancelled = $dnsZone !== null && (int) $dnsZone->is_created === DnsDomain::STATUS_CANCELLED;
|
||||
|
||||
return [
|
||||
$payload = [
|
||||
'uuid' => $this->uuid,
|
||||
'type' => $this->type,
|
||||
'group' => ServiceGroup::of($this->orderItem?->product?->product_type ?? $this->product?->product_type, $this->orderItem?->product?->name ?? $this->product?->name),
|
||||
'server_name' => $this->server_name,
|
||||
'status' => $this->status,
|
||||
'auto_renew' => $this->auto_renew,
|
||||
@@ -132,8 +136,8 @@ class ServiceClientDetailResource extends JsonResource
|
||||
'vm' => $vm ? [
|
||||
'ip_address' => $vm->ipAddress?->ip ?? $vm->ip,
|
||||
'hostname' => $vm->hostname,
|
||||
'username' => $vm->user,
|
||||
'password' => $vm->plain_password ? self::MASKED_PASSWORD : null,
|
||||
'username' => ResponseCipher::encrypt($vm->user),
|
||||
'password' => ResponseCipher::encrypt($vm->plain_password),
|
||||
'port' => $vm->port,
|
||||
'status' => $vm->status,
|
||||
'ip' => $vm->ip ?? null,
|
||||
@@ -141,17 +145,29 @@ class ServiceClientDetailResource extends JsonResource
|
||||
] : null,
|
||||
'hosting' => $hosting ? [
|
||||
'ip_address' => $hosting->server?->publicHost(),
|
||||
'plesk_url' => $hosting->server
|
||||
? "http://{$hosting->server->publicHost()}:{$hosting->server->panel_port}/"
|
||||
: null,
|
||||
'hosting_plan' => $hosting->hosting_plan,
|
||||
'ftp_username' => $hosting->ftp_username,
|
||||
'username' => $pleskCredentials['login'] ?? null,
|
||||
'password' => $pleskCredentials['password'] ?? null,
|
||||
'status' => $hosting->status,
|
||||
'plesk_url' => $hosting->server?->panelUrl(),
|
||||
'username' => ResponseCipher::encrypt($pleskCredentials['login'] ?? null),
|
||||
'password' => ResponseCipher::encrypt($pleskCredentials['password'] ?? null),
|
||||
] : null,
|
||||
'attachments' => \App\Models\ServiceAttachment::forCustomer()
|
||||
->where('service_id', $this->resource->id)
|
||||
->latest()
|
||||
->get()
|
||||
->map(fn ($file) => [
|
||||
'id' => $file->id,
|
||||
'filename' => $file->filename,
|
||||
'mime_type' => $file->mime_type,
|
||||
'size' => (int) $file->size,
|
||||
'uploaded_at' => $file->created_at?->toISOString(),
|
||||
])
|
||||
->values(),
|
||||
'cancel_at_expiry' => (bool) $this->cancel_at_expiry,
|
||||
'cancellation_request' => \App\Http\Controllers\Api\Client\Service\ServiceCancellationController::payload($this->resource->cancellationRequests()->pending()->latest()->first()),
|
||||
'ssl_certificate' => $this->resource->sslCertificate?->data,
|
||||
'dns_domain_id' => $dnsZone && !$dnsCancelled ? $dnsZone->id : null,
|
||||
'dns_cancelled' => $dnsCancelled,
|
||||
];
|
||||
|
||||
return Arr::except($payload, self::IRRELEVANT_KEYS[$this->type] ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Enums\Order\OrderStatus;
|
||||
use App\Models\InvoiceAttachment;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class InvoiceDetailResource extends JsonResource
|
||||
{
|
||||
@@ -33,6 +34,9 @@ class InvoiceDetailResource extends JsonResource
|
||||
'auto_renew' => (bool) $this->auto_renew,
|
||||
'created_at' => $this->created_at?->toISOString(),
|
||||
'due_date' => $this->due_date?->toISOString(),
|
||||
'billing_period_start' => $this->billing_period_start ? Carbon::parse($this->billing_period_start)->toDateString() : null,
|
||||
'billing_period_end' => $this->billing_period_end ? Carbon::parse($this->billing_period_end)->toDateString() : null,
|
||||
'next_renewal_date' => $this->whenLoaded('order', fn () => $this->nextRenewalDate()),
|
||||
'paid_at' => $this->paid_at?->toISOString(),
|
||||
'customer' => $this->whenLoaded('customer', fn () => [
|
||||
'name' => $this->customer->fullname,
|
||||
@@ -46,7 +50,7 @@ class InvoiceDetailResource extends JsonResource
|
||||
'total' => (float) $this->order->total,
|
||||
'created_at' => $this->order->created_at?->toISOString(),
|
||||
]),
|
||||
'items' => $this->whenLoaded('order', fn () => $this->order->orderItems->map(fn ($item) => [
|
||||
'items' => $this->whenLoaded('order', fn () => $this->isRenewal() ? $this->renewalItems() : $this->order->orderItems->map(fn ($item) => [
|
||||
'product_name' => $item->product?->name,
|
||||
'server_name' => $item->server_name,
|
||||
'billing_cycle' => $item->billing_cycle,
|
||||
@@ -70,19 +74,59 @@ class InvoiceDetailResource extends JsonResource
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveService(): ?array
|
||||
private function isRenewal(): bool
|
||||
{
|
||||
return (int) $this->invoice_type === 2;
|
||||
}
|
||||
|
||||
private function resolveOrderItem(): mixed
|
||||
{
|
||||
$items = $this->order->orderItems;
|
||||
$item = $this->service_id
|
||||
? $items->first(fn ($i) => (int) $i->service?->id === (int) $this->service_id)
|
||||
: null;
|
||||
$item ??= $items->first();
|
||||
|
||||
return $item ?? $items->first();
|
||||
}
|
||||
|
||||
private function renewalMonths(): ?int
|
||||
{
|
||||
return $this->invoiceItems->firstWhere('type', 'renewal')?->months;
|
||||
}
|
||||
|
||||
private function renewalItems(): array
|
||||
{
|
||||
$serverName = $this->resolveOrderItem()?->server_name;
|
||||
|
||||
return $this->invoiceItems->map(fn ($item) => [
|
||||
'product_name' => $item->description,
|
||||
'server_name' => $serverName,
|
||||
'billing_cycle' => $item->months ? 'monthly' : null,
|
||||
'price' => (float) $item->total_price,
|
||||
'months' => $item->months,
|
||||
'os_id' => null,
|
||||
])->values()->all();
|
||||
}
|
||||
|
||||
private function nextRenewalDate(): ?string
|
||||
{
|
||||
if ($this->isRenewal() && $this->billing_period_end) {
|
||||
return Carbon::parse($this->billing_period_end)->toDateString();
|
||||
}
|
||||
|
||||
return $this->resolveOrderItem()?->service?->expires_at?->toDateString();
|
||||
}
|
||||
|
||||
private function resolveService(): ?array
|
||||
{
|
||||
$item = $this->resolveOrderItem();
|
||||
$renewalMonths = $this->isRenewal() ? $this->renewalMonths() : null;
|
||||
|
||||
return $item ? [
|
||||
'product_id' => $item->product_id,
|
||||
'name' => $item->product?->name,
|
||||
'billing_cycle' => $item->billing_cycle,
|
||||
'months' => $item->months,
|
||||
'billing_cycle' => $renewalMonths ? 'monthly' : $item->billing_cycle,
|
||||
'months' => $renewalMonths ?: $item->months,
|
||||
'server_name' => $item->server_name,
|
||||
'product_type' => $item->product?->product_type,
|
||||
] : null;
|
||||
|
||||
@@ -18,6 +18,8 @@ class OrderItemResource extends JsonResource
|
||||
'price' => (float) $this->price,
|
||||
'service' => $this->whenLoaded('service', fn () => [
|
||||
'id' => $this->service->id,
|
||||
'uuid' => $this->service->uuid,
|
||||
'type' => $this->service->type,
|
||||
'status' => $this->service->status,
|
||||
'status_text' => $this->service->status_label,
|
||||
'retry_requested_at' => $this->service->retry_requested_at?->toISOString(),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Resources\Api\Service;
|
||||
|
||||
use App\Models\Product;
|
||||
use App\Support\ServiceGroup;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
@@ -38,12 +39,14 @@ class VpsServiceResource extends JsonResource
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'type' => $type,
|
||||
'group' => ServiceGroup::of($product?->product_type, $product?->name),
|
||||
'server_name' => $this->server_name,
|
||||
'invoice_num' => $invoice?->invoice_number,
|
||||
'order_num' => $order?->order_num,
|
||||
'status' => $this->status,
|
||||
'status_label' => $this->status_label,
|
||||
'auto_renew' => (bool) $this->auto_renew,
|
||||
'cancel_at_expiry' => (bool) $this->cancel_at_expiry,
|
||||
'started_at' => $this->started_at?->toISOString(),
|
||||
'expires_at' => $this->expires_at?->toISOString(),
|
||||
'product' => $product ? [
|
||||
|
||||
Reference in New Issue
Block a user