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
@@ -93,3 +93,4 @@ CLIENT_API_ACCESS_KEY=
|
||||
CLIENT_API_ACCESS_SECRET=
|
||||
|
||||
WMKS_HOST=10.10.10.120
|
||||
CLIENT_RESPONSE_KEY=
|
||||
|
||||
@@ -15,6 +15,7 @@ use App\Services\AutoRenewFailureNotifier;
|
||||
use App\Services\InvoicePaymentService;
|
||||
use App\Services\RenewalInvoiceNotifier;
|
||||
use App\Support\RenewalPricing;
|
||||
use App\Support\ServiceCancellation;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -33,6 +34,7 @@ class ServiceExpiry extends Command
|
||||
->with(['orderItem.product', 'customer'])
|
||||
->whereNotNull('expires_at')
|
||||
->whereBetween('expires_at', [now(), now()->addDays($renewalWindowDays)])
|
||||
->where('cancel_at_expiry', false)
|
||||
->whereNotIn('status', ['terminated', 'cancelled'])
|
||||
->get();
|
||||
|
||||
@@ -63,6 +65,17 @@ class ServiceExpiry extends Command
|
||||
|
||||
foreach ($expired as $service) {
|
||||
try {
|
||||
if ($service->cancel_at_expiry) {
|
||||
if ($dryRun) {
|
||||
$this->line(" [dry-run:cancel] Service #{$service->id}");
|
||||
} else {
|
||||
ServiceCancellation::finalize($service);
|
||||
Log::info('Service cancelled at expiry', ['service_id' => $service->id]);
|
||||
$this->line(" [cancelled] Service #{$service->id}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Hướng tính tường minh (mốc quá khứ → now), absolute: true để không phụ thuộc
|
||||
// việc Carbon 2 trả trị tuyệt đối — Carbon 3 trả số âm, điều kiện >= 30 sẽ
|
||||
// không bao giờ đúng và không dịch vụ nào bị terminate nữa, hoàn toàn im lặng.
|
||||
@@ -132,6 +145,7 @@ class ServiceExpiry extends Command
|
||||
$services = Service::query()
|
||||
->with('customer')
|
||||
->where('auto_renew', true)
|
||||
->where('cancel_at_expiry', false)
|
||||
->whereNotNull('invoice_id')
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '<=', now()->addDays($windowDays))
|
||||
|
||||
@@ -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 ? [
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail\Admin;
|
||||
|
||||
use App\Mail\TemplateMailable;
|
||||
use Illuminate\Bus\Queueable;
|
||||
|
||||
class ServiceCancellationRequestMail extends TemplateMailable
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(public array $data) {}
|
||||
|
||||
protected function scenario(): string
|
||||
{
|
||||
return 'admin_service_cancellation_request';
|
||||
}
|
||||
|
||||
protected function variables(): array
|
||||
{
|
||||
return $this->data;
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,11 @@ class DnsServer extends Model
|
||||
return $this->domain ?: $this->ip_address;
|
||||
}
|
||||
|
||||
public function panelUrl(): string
|
||||
{
|
||||
return 'https://' . $this->publicHost() . '/';
|
||||
}
|
||||
|
||||
protected function scopeIsActive($query)
|
||||
{
|
||||
return $query->where('status', 'activated');
|
||||
|
||||
@@ -28,4 +28,12 @@ class ModuleHostingPleskClient extends Model
|
||||
{
|
||||
return $this->belongsTo(DnsServer::class, 'dns_manager_server_id');
|
||||
}
|
||||
|
||||
public static function forHosting($hosting): ?self
|
||||
{
|
||||
return static::query()
|
||||
->where('customer_id', $hosting->customer_id)
|
||||
->where('dns_manager_server_id', $hosting->dns_manager_server_id)
|
||||
->first();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,16 @@ class Service extends Model
|
||||
|
||||
protected $table = 'services';
|
||||
|
||||
public function cancellationRequests()
|
||||
{
|
||||
return $this->hasMany(ServiceCancellationRequest::class);
|
||||
}
|
||||
|
||||
public function sslCertificate()
|
||||
{
|
||||
return $this->hasOne(ServiceSslCertificate::class);
|
||||
}
|
||||
|
||||
public const HOSTING_ACCOUNT_RELATIONS = [
|
||||
'hostingAccount.server:id,name,ip_address,domain,panel_port',
|
||||
'hostingAccount.domain:id,domain_name',
|
||||
@@ -53,6 +63,7 @@ class Service extends Model
|
||||
'server_name',
|
||||
'status',
|
||||
'auto_renew',
|
||||
'cancel_at_expiry',
|
||||
'started_at',
|
||||
'expires_at',
|
||||
'suspended_at',
|
||||
@@ -71,6 +82,7 @@ class Service extends Model
|
||||
|
||||
protected $casts = [
|
||||
'auto_renew' => 'boolean',
|
||||
'cancel_at_expiry' => 'boolean',
|
||||
'started_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'suspended_at' => 'datetime',
|
||||
|
||||
@@ -9,10 +9,18 @@ class ServiceAttachment extends Model
|
||||
{
|
||||
public const UPLOAD_RULE = 'required|file|mimes:jpg,jpeg,png,gif,webp,pdf,doc,docx,xls,xlsx,zip|max:51200';
|
||||
|
||||
public const AUDIENCE_ADMIN = 'admin';
|
||||
public const AUDIENCE_CUSTOMER = 'customer';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $appends = ['url'];
|
||||
|
||||
public function scopeForCustomer($query)
|
||||
{
|
||||
return $query->where('audience', self::AUDIENCE_CUSTOMER);
|
||||
}
|
||||
|
||||
public function service()
|
||||
{
|
||||
return $this->belongsTo(Service::class);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ServiceCancellationRequest extends Model
|
||||
{
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_APPROVED = 'approved';
|
||||
|
||||
public const STATUS_REJECTED = 'rejected';
|
||||
|
||||
protected $casts = ['resolved_at' => 'datetime'];
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
public function service()
|
||||
{
|
||||
return $this->belongsTo(Service::class);
|
||||
}
|
||||
|
||||
public function customer()
|
||||
{
|
||||
return $this->belongsTo(Customer::class);
|
||||
}
|
||||
|
||||
public function scopePending($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_PENDING);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ServiceSslCertificate extends Model
|
||||
{
|
||||
public const MAX_ENTRIES = 30;
|
||||
|
||||
public const MAX_FILE_BYTES = 524288;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = ['data' => 'encrypted:array'];
|
||||
|
||||
public function service()
|
||||
{
|
||||
return $this->belongsTo(Service::class);
|
||||
}
|
||||
|
||||
public static function emptyData(): array
|
||||
{
|
||||
return ['fields' => [], 'files' => []];
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,10 @@ class AdminProductRepository implements AdminProductRepositoryInterface
|
||||
$query->where('product_type', $type);
|
||||
}
|
||||
|
||||
if ($categoryId = $filters['product_categories_id'] ?? null) {
|
||||
$query->where('product_categories_id', $categoryId);
|
||||
}
|
||||
|
||||
if (isset($filters['is_active']) && $filters['is_active'] !== '') {
|
||||
$query->where('is_active', filter_var($filters['is_active'], FILTER_VALIDATE_BOOLEAN));
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Repositories\Api\ServiceClient;
|
||||
|
||||
use App\Support\ProductBillingCycles;
|
||||
use App\Support\ServiceGroup;
|
||||
use App\Models\Service;
|
||||
use App\Repositories\Api\BaseApiRepository;
|
||||
|
||||
@@ -16,7 +17,7 @@ class ServiceClientRepository extends BaseApiRepository implements ServiceClient
|
||||
/**
|
||||
* Lấy toàn bộ dịch vụ của customer (mọi trạng thái)
|
||||
*/
|
||||
public function getActiveServices(int $customerId, ?string $productType = null, ?int $limit = 10)
|
||||
public function getActiveServices(int $customerId, ?string $productType = null, ?int $limit = 10, ?string $group = null)
|
||||
{
|
||||
$query = $this->model
|
||||
->with([
|
||||
@@ -33,6 +34,10 @@ class ServiceClientRepository extends BaseApiRepository implements ServiceClient
|
||||
$query->where('type', $productType);
|
||||
}
|
||||
|
||||
if ($group) {
|
||||
$query->whereHas('product', fn ($products) => ServiceGroup::constrain($products, $group));
|
||||
}
|
||||
|
||||
return $limit === null ? $query->get() : $query->paginate($limit);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ interface ServiceClientRepositoryInterface extends BaseRepositoryInterface
|
||||
/**
|
||||
* Lấy danh sách sản phẩm đang hoạt động
|
||||
*/
|
||||
public function getActiveServices(int $customerId, ?string $productType = null, ?int $limit = 10);
|
||||
public function getActiveServices(int $customerId, ?string $productType = null, ?int $limit = 10, ?string $group = null);
|
||||
|
||||
public function findActiveBySlug(string $slug);
|
||||
|
||||
|
||||
@@ -220,7 +220,7 @@ class OrderQueryService
|
||||
->with(['orderItem.product', 'customProduct'])
|
||||
->where('id', $serviceId)
|
||||
->where('customer_id', $customer->id)
|
||||
->whereNotIn('status', ['terminated', 'cancelled'])
|
||||
->where('status', '!=', 'terminated')
|
||||
->first();
|
||||
|
||||
if (! $service) {
|
||||
|
||||
@@ -18,9 +18,9 @@ class ServiceClientService
|
||||
/**
|
||||
* Lấy danh sách dịch vụ đang hoạt động
|
||||
*/
|
||||
public function getActiveServices(int $customerId, ?string $productType = null, ?int $limit = 10)
|
||||
public function getActiveServices(int $customerId, ?string $productType = null, ?int $limit = 10, ?string $group = null)
|
||||
{
|
||||
return $this->serviceClientRepository->getActiveServices($customerId, $productType, $limit);
|
||||
return $this->serviceClientRepository->getActiveServices($customerId, $productType, $limit, $group);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,7 +15,9 @@ use App\Models\OrderItem;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceAddon;
|
||||
use App\Services\ActivityLogger;
|
||||
use App\Services\CustomerRenewalNotifier;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
@@ -94,13 +96,30 @@ trait ActivatesPaidInvoices
|
||||
: now();
|
||||
$expiresAt = Carbon::parse($baseDate)->addMonths($months);
|
||||
$previousExpiresAt = $service->expires_at;
|
||||
$previousStatus = $service->status;
|
||||
$wasCancelled = $previousStatus === 'cancelled';
|
||||
$wasScheduledCancel = (bool) $service->cancel_at_expiry;
|
||||
|
||||
$service->update([
|
||||
'status' => 'active',
|
||||
'expires_at' => $expiresAt,
|
||||
'suspended_at' => null,
|
||||
'cancel_at_expiry' => false,
|
||||
'cancelled_at' => null,
|
||||
]);
|
||||
|
||||
if ($wasCancelled || $wasScheduledCancel) {
|
||||
app(ActivityLogger::class)->log(
|
||||
event: 'cancellation_revoked',
|
||||
logName: 'Service',
|
||||
description: "Dịch vụ #{$service->id} được gia hạn nên huỷ bỏ việc hủy dịch vụ"
|
||||
.($wasCancelled ? ' (đã hủy → hoạt động lại)' : ' (hủy cuối kỳ)'),
|
||||
subject: $service,
|
||||
oldValues: ['status' => $previousStatus, 'cancel_at_expiry' => $wasScheduledCancel],
|
||||
newValues: ['status' => 'active', 'cancel_at_expiry' => false, 'invoice_number' => $invoice->invoice_number],
|
||||
);
|
||||
}
|
||||
|
||||
app(ActivityLogger::class)->log(
|
||||
event: 'renewed',
|
||||
logName: 'Service',
|
||||
@@ -119,6 +138,21 @@ trait ActivatesPaidInvoices
|
||||
if ($service->vm_id) {
|
||||
dispatch(new VMJobs((int) $service->vm_id, 'Start', false))->delay(now()->addSeconds(5));
|
||||
}
|
||||
|
||||
$this->notifyAdminsOfRenewal($service, $invoice, $months, $previousExpiresAt);
|
||||
}
|
||||
|
||||
private function notifyAdminsOfRenewal(Service $service, Invoice $invoice, int $months, ?CarbonInterface $previousExpiresAt): void
|
||||
{
|
||||
if (auth()->guard('admin')->check()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$source = auth()->guard('client')->check()
|
||||
? CustomerRenewalNotifier::SOURCE_CUSTOMER
|
||||
: CustomerRenewalNotifier::SOURCE_SYSTEM;
|
||||
|
||||
DB::afterCommit(fn () => app(CustomerRenewalNotifier::class)->notify($service->fresh(), $invoice, $months, $previousExpiresAt, $source));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -51,6 +51,7 @@ class CustomerServiceProvisioningService
|
||||
);
|
||||
|
||||
ModuleVmware::resolveVirtualMachineIds($services->pluck('vmware'));
|
||||
\App\Support\HostingCredentials::attach($services);
|
||||
|
||||
return $services;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Mail\ServiceRenewedMail;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Notification;
|
||||
use App\Models\Service;
|
||||
use App\Models\Setting;
|
||||
use App\Support\AutoEmailGate;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class CustomerRenewalNotifier
|
||||
{
|
||||
public const SCENARIO = 'admin_customer_service_renewed';
|
||||
|
||||
public const SOURCE_CUSTOMER = 'customer';
|
||||
|
||||
public const SOURCE_SYSTEM = 'system';
|
||||
|
||||
public function notify(Service $service, Invoice $invoice, int $months, ?CarbonInterface $previousExpiresAt, string $source): void
|
||||
{
|
||||
$service->loadMissing(['customer', 'product']);
|
||||
$customer = $service->customer;
|
||||
if (! $customer) {
|
||||
return;
|
||||
}
|
||||
|
||||
$productName = $service->product?->name ?? 'Dịch vụ';
|
||||
$serverName = (string) $service->server_name;
|
||||
$expiresAt = $service->expires_at?->format('d/m/Y') ?? 'N/A';
|
||||
$auto = $source === self::SOURCE_SYSTEM;
|
||||
$amount = number_format((float) $invoice->total, 0, ',', '.') . ' VNĐ';
|
||||
|
||||
Notification::notifyAdmins([
|
||||
'type' => 'service_renewed_by_customer',
|
||||
'title' => $auto ? 'Dịch vụ được tự động gia hạn' : 'Khách hàng gia hạn dịch vụ',
|
||||
'body' => "{$customer->fullname} " . ($auto ? 'được tự động gia hạn' : 'đã gia hạn') . " {$productName}" . ($serverName !== '' ? " ({$serverName})" : '') . " thêm {$months} tháng ({$amount}), hạn mới {$expiresAt}.",
|
||||
'link' => '/services/' . $service->id,
|
||||
'data' => ['service_id' => $service->id, 'invoice_id' => $invoice->id, 'months' => $months],
|
||||
]);
|
||||
|
||||
$adminEmail = json_decode((string) Setting::where('key', 'setting')->value('value'), true)['admin_email'] ?? null;
|
||||
if (! $adminEmail || ! AutoEmailGate::allows(self::SCENARIO)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Mail::to($adminEmail)->queue(new ServiceRenewedMail(self::SCENARIO, [
|
||||
'customer_name' => (string) $customer->fullname,
|
||||
'customer_email' => (string) $customer->email,
|
||||
'product_name' => $productName,
|
||||
'server_name_suffix' => $serverName !== '' ? ' (' . e($serverName) . ')' : '',
|
||||
'months_label' => "{$months} tháng",
|
||||
'old_expires_at' => $previousExpiresAt?->format('d/m/Y') ?? 'N/A',
|
||||
'expires_at' => $expiresAt,
|
||||
'invoice_number' => (string) $invoice->invoice_number,
|
||||
'amount_formatted' => $amount,
|
||||
'payment_method_label' => $invoice->payment_method === 'balance' ? 'Trừ số dư tài khoản' : (string) ($invoice->payment_method ?: 'N/A'),
|
||||
'renewal_source_label' => $auto ? 'Tự động gia hạn (hệ thống)' : 'Khách hàng tự gia hạn',
|
||||
'admin_url' => rtrim(env('ADMIN_URL', config('app.url')), '/') . '/services/' . $service->id,
|
||||
]));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('CustomerRenewalNotifier: không gửi được email admin', ['service_id' => $service->id, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,17 @@ class PleskClientService
|
||||
}
|
||||
}
|
||||
|
||||
public function changePassword(DnsServer $server, ModuleHostingPleskClient $client, string $password): bool
|
||||
{
|
||||
if (!$this->resetPassword($server, (int) $client->plesk_client_id, $password)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$client->update(['password' => Crypt::encryptString($password)]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function resetPassword(DnsServer $server, int $pleskClientId, string $password): bool
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -138,8 +138,7 @@ class PleskHostingService
|
||||
'ftp_password' => $ftpPassword,
|
||||
'plesk_login' => $pleskLogin,
|
||||
'plesk_password' => $pleskPassword,
|
||||
// Plesk's actual login page — not the :8443 REST API entry point.
|
||||
'plesk_panel_url' => 'https://' . $server->publicHost() . '/login_up.php',
|
||||
'plesk_panel_url' => $server->panelUrl(),
|
||||
'created_at' => now()->format('d/m/Y H:i:s'),
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Mail\Admin\ServiceCancellationRequestMail;
|
||||
use App\Models\Notification;
|
||||
use App\Models\ServiceCancellationRequest;
|
||||
use App\Models\Setting;
|
||||
use App\Support\AutoEmailGate;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class ServiceCancellationNotifier
|
||||
{
|
||||
public function notify(ServiceCancellationRequest $request, ?string $clientIp): void
|
||||
{
|
||||
$request->loadMissing(['service.product', 'customer']);
|
||||
$service = $request->service;
|
||||
$customer = $request->customer;
|
||||
$serviceName = $service->server_name ?: ($service->product?->name ?? 'Dịch vụ');
|
||||
|
||||
Notification::notifyAdmins([
|
||||
'type' => 'service_cancellation_request',
|
||||
'title' => 'Khách hàng yêu cầu hủy dịch vụ',
|
||||
'body' => "{$customer->fullname} yêu cầu hủy dịch vụ {$serviceName}. Lý do: {$request->reason}",
|
||||
'link' => '/services/' . $service->id,
|
||||
'data' => ['service_id' => $service->id, 'request_id' => $request->id],
|
||||
]);
|
||||
|
||||
$adminEmail = json_decode((string) Setting::where('key', 'setting')->value('value'), true)['admin_email'] ?? null;
|
||||
if (! $adminEmail || ! AutoEmailGate::allows('admin_service_cancellation_request')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Mail::to($adminEmail)->queue(new ServiceCancellationRequestMail([
|
||||
'customer_name' => (string) $customer->fullname,
|
||||
'customer_email' => (string) $customer->email,
|
||||
'product_name' => (string) ($service->product?->name ?? 'Dịch vụ'),
|
||||
'server_name' => (string) $service->server_name,
|
||||
'expires_at' => $service->expires_at?->format('d/m/Y') ?? 'N/A',
|
||||
'cancel_reason' => $request->reason,
|
||||
'client_ip' => (string) $clientIp,
|
||||
'created_at' => $request->created_at->format('d/m/Y H:i:s'),
|
||||
'admin_url' => rtrim(env('ADMIN_URL', config('app.url')), '/') . '/services/' . $service->id,
|
||||
]));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Không gửi được email yêu cầu hủy dịch vụ', ['service_id' => $service->id, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ class ServiceRenewalReminderService
|
||||
{
|
||||
return Service::with(['customer:id,uuid,fullname,email', 'product:id,name'])
|
||||
->where('status', 'active')
|
||||
->where('cancel_at_expiry', false)
|
||||
->whereHas('customer')
|
||||
->whereBetween('expires_at', [now()->startOfDay(), now()->addDays($days)->endOfDay()])
|
||||
->orderBy('expires_at')
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Support\Billing;
|
||||
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Service;
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -43,11 +45,12 @@ class InvoicePaymentHistory
|
||||
->get();
|
||||
|
||||
$invoiceByNumber = $invoices->keyBy('invoice_number');
|
||||
$renewalCausers = self::renewalCausers($invoices);
|
||||
|
||||
foreach ($transactions as $transaction) {
|
||||
$invoice = self::matchInvoice($transaction, $invoiceByNumber, $invoiceByOrderNum);
|
||||
if ($invoice) {
|
||||
$history[$invoice->id][] = self::present($transaction);
|
||||
$history[$invoice->id][] = self::present($transaction, $renewalCausers[$invoice->invoice_number] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +68,42 @@ class InvoicePaymentHistory
|
||||
return $invoiceByNumber->get($number);
|
||||
}
|
||||
|
||||
private static function present(Transaction $transaction): array
|
||||
private static function renewalCausers(Collection $invoices): array
|
||||
{
|
||||
$numbers = $invoices->where('invoice_type', 2)->pluck('invoice_number')->all();
|
||||
if ($numbers === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ActivityLog::where('subject_type', Service::class)
|
||||
->where('event', 'renewed')
|
||||
->whereIn('new_values->invoice_number', $numbers)
|
||||
->get(['causer_type', 'new_values'])
|
||||
->mapWithKeys(fn (ActivityLog $log) => [$log->new_values['invoice_number'] => $log->causer_type])
|
||||
->all();
|
||||
}
|
||||
|
||||
private static function method(Transaction $transaction, bool $isRefund, ?string $renewalCauser): string
|
||||
{
|
||||
if ($isRefund) {
|
||||
return 'refund';
|
||||
}
|
||||
|
||||
if (in_array($transaction->type, ['transfer', 'balance'], true)) {
|
||||
return $transaction->type;
|
||||
}
|
||||
|
||||
if (preg_match('/\(([a-z_]+)\)$/', (string) $transaction->note, $match) !== 1) {
|
||||
return 'other';
|
||||
}
|
||||
|
||||
return match (true) {
|
||||
$match[1] === 'balance' && $renewalCauser === 'system' => 'auto_renew',
|
||||
default => $match[1],
|
||||
};
|
||||
}
|
||||
|
||||
private static function present(Transaction $transaction, ?string $renewalCauser): array
|
||||
{
|
||||
$isRefund = Str::endsWith((string) $transaction->trans, '-REFUND');
|
||||
|
||||
@@ -75,7 +113,8 @@ class InvoicePaymentHistory
|
||||
'kind' => $isRefund ? 'refund' : 'payment',
|
||||
'amount' => abs((int) $transaction->amount),
|
||||
'type' => $transaction->type,
|
||||
'note' => $transaction->note,
|
||||
'method' => self::method($transaction, $isRefund, $renewalCauser),
|
||||
'note' => preg_replace('/ \([a-z_]+\)$/', '', (string) $transaction->note) ?: null,
|
||||
'recorded_by' => $transaction->user?->fullname ?: $transaction->user?->username,
|
||||
'created_at' => $transaction->created_at,
|
||||
];
|
||||
|
||||
@@ -189,6 +189,14 @@ final class EmailScenarioRegistry
|
||||
'label' => '[Admin] Ticket hỗ trợ mới', 'category' => self::INTERNAL, 'group' => 'SUPPORT',
|
||||
'required_variables' => ['ticket_num'],
|
||||
],
|
||||
'admin_customer_service_renewed' => [
|
||||
'label' => '[Admin] Khách hàng gia hạn dịch vụ', 'category' => self::INTERNAL, 'group' => 'SERVICE',
|
||||
'required_variables' => ['product_name', 'expires_at'],
|
||||
],
|
||||
'admin_service_cancellation_request' => [
|
||||
'label' => '[Admin] Khách yêu cầu hủy dịch vụ', 'category' => self::INTERNAL, 'group' => 'SERVICE',
|
||||
'required_variables' => ['customer_name', 'cancel_reason'],
|
||||
],
|
||||
'admin_login_alert' => [
|
||||
'label' => '[Admin] Có người đăng nhập trang quản trị', 'category' => self::INTERNAL, 'group' => 'SECURITY',
|
||||
'required_variables' => ['admin_username', 'client_ip', 'login_time'],
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\ModuleHostingPleskClient;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
|
||||
class HostingCredentials
|
||||
{
|
||||
public static function attach(iterable $services): void
|
||||
{
|
||||
foreach ($services as $service) {
|
||||
$service->setAttribute('hosting_credentials', self::for($service));
|
||||
}
|
||||
}
|
||||
|
||||
public static function for(Service $service): ?array
|
||||
{
|
||||
$hosting = $service->hostingAccount;
|
||||
$client = $hosting ? ModuleHostingPleskClient::forHosting($hosting) : null;
|
||||
|
||||
if (!$client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'host' => $hosting->server?->publicHost(),
|
||||
'ip' => $hosting->server?->ip_address,
|
||||
'panel_url' => $hosting->server?->panelUrl(),
|
||||
'username' => $client->login,
|
||||
'password' => Crypt::decryptString($client->password),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class ResponseCipher
|
||||
{
|
||||
private const CIPHER = 'aes-256-gcm';
|
||||
|
||||
public static function keyFrom(?string $encoded): ?string
|
||||
public static function encrypt(?string $value): ?string
|
||||
{
|
||||
$key = $encoded ? base64_decode($encoded, true) : false;
|
||||
|
||||
return $key !== false && strlen($key) === 32 ? $key : null;
|
||||
}
|
||||
|
||||
public static function seal(string $plain, string $key): string
|
||||
{
|
||||
$iv = random_bytes(12);
|
||||
$cipherText = openssl_encrypt($plain, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv, $tag);
|
||||
|
||||
return base64_encode($iv . $cipherText . $tag);
|
||||
}
|
||||
|
||||
public static function open(string $sealed, string $key): ?string
|
||||
{
|
||||
$raw = base64_decode($sealed, true);
|
||||
if ($raw === false || strlen($raw) < 29) {
|
||||
return null;
|
||||
if ($value === null || $value === '') {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$plain = openssl_decrypt(
|
||||
substr($raw, 12, -16),
|
||||
self::CIPHER,
|
||||
$key,
|
||||
OPENSSL_RAW_DATA,
|
||||
substr($raw, 0, 12),
|
||||
substr($raw, -16),
|
||||
);
|
||||
$key = (string) config('services.client_api.response_key');
|
||||
if (!preg_match('/^[a-fA-F0-9]{64}$/', $key)) {
|
||||
throw new RuntimeException('CLIENT_RESPONSE_KEY must be a 64-character hex string');
|
||||
}
|
||||
|
||||
return $plain === false ? null : $plain;
|
||||
$iv = random_bytes(12);
|
||||
$ciphertext = openssl_encrypt($value, 'aes-256-gcm', hex2bin($key), OPENSSL_RAW_DATA, $iv, $tag);
|
||||
|
||||
return base64_encode($iv . $ciphertext . $tag);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Enums\Invoice\InvoiceStatus;
|
||||
use App\Jobs\VirtualMachine\Action\VMJobs;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Service;
|
||||
|
||||
class ServiceCancellation
|
||||
{
|
||||
public const SCHEDULED = 'scheduled';
|
||||
|
||||
public const IMMEDIATE = 'immediate';
|
||||
|
||||
public static function approve(Service $service): string
|
||||
{
|
||||
self::voidOpenRenewalInvoices($service);
|
||||
|
||||
if (! $service->expires_at || $service->expires_at->isPast()) {
|
||||
$service->update(['status' => 'cancelled', 'cancelled_at' => now(), 'auto_renew' => false]);
|
||||
|
||||
return self::IMMEDIATE;
|
||||
}
|
||||
|
||||
$service->update(['cancel_at_expiry' => true, 'auto_renew' => false]);
|
||||
|
||||
return self::SCHEDULED;
|
||||
}
|
||||
|
||||
public static function finalize(Service $service): void
|
||||
{
|
||||
$service->update(['status' => 'cancelled', 'cancelled_at' => now()]);
|
||||
|
||||
if ($service->vm_id) {
|
||||
dispatch(new VMJobs((int) $service->vm_id, 'PowerOff', false));
|
||||
}
|
||||
}
|
||||
|
||||
private static function voidOpenRenewalInvoices(Service $service): void
|
||||
{
|
||||
$orderId = $service->orderItem?->order_id;
|
||||
if (! $orderId) {
|
||||
return;
|
||||
}
|
||||
|
||||
Invoice::where('order_id', $orderId)
|
||||
->where('invoice_type', 2)
|
||||
->whereIn('status', [InvoiceStatus::UNPAID->value, InvoiceStatus::OVERDUE->value])
|
||||
->where(fn ($query) => $query->whereNull('amount_paid')->orWhere('amount_paid', 0))
|
||||
->get()
|
||||
->each(fn (Invoice $invoice) => $invoice->update(['status' => InvoiceStatus::CANCELLED->value]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class ServiceGroup
|
||||
{
|
||||
public const SSL = 'ssl';
|
||||
|
||||
public const COLO = 'colo';
|
||||
|
||||
public const DEDICATED = 'dedicated';
|
||||
|
||||
public const EMAIL = 'email';
|
||||
|
||||
public const ALL = [self::SSL, self::COLO, self::DEDICATED, self::EMAIL];
|
||||
|
||||
private const UNTYPED_PRODUCT_TYPES = ['other', 'server_rental'];
|
||||
|
||||
public static function of(?string $productType, ?string $productName): ?string
|
||||
{
|
||||
$name = mb_strtolower(trim((string) $productName));
|
||||
|
||||
return match (true) {
|
||||
$productType === 'email' => self::EMAIL,
|
||||
$productType === 'ssl' => self::SSL,
|
||||
! in_array($productType, self::UNTYPED_PRODUCT_TYPES, true) => null,
|
||||
str_starts_with($name, 'ssl') => self::SSL,
|
||||
str_contains($name, 'colo') || str_contains($name, 'chỗ đặt') => self::COLO,
|
||||
$productType === 'server_rental' || str_contains($name, 'dedicated') => self::DEDICATED,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public static function constrain(Builder $products, string $group): Builder
|
||||
{
|
||||
$untyped = fn (Builder $query) => $query->whereIn('product_type', self::UNTYPED_PRODUCT_TYPES);
|
||||
$notSsl = fn (Builder $query) => $query->whereRaw("TRIM(name) NOT LIKE 'ssl%'");
|
||||
$notColo = fn (Builder $query) => $query->where('name', 'not like', '%colo%')->where('name', 'not like', '%chỗ đặt%');
|
||||
|
||||
return match ($group) {
|
||||
self::EMAIL => $products->where('product_type', 'email'),
|
||||
self::SSL => $products->where(fn (Builder $query) => $query
|
||||
->where('product_type', 'ssl')
|
||||
->orWhere(fn (Builder $other) => $untyped($other)->whereRaw("TRIM(name) LIKE 'ssl%'"))),
|
||||
self::COLO => $untyped($products)->tap($notSsl)->where(fn (Builder $query) => $query
|
||||
->where('name', 'like', '%colo%')
|
||||
->orWhere('name', 'like', '%chỗ đặt%')),
|
||||
self::DEDICATED => $untyped($products)->tap($notSsl)->tap($notColo)->where(fn (Builder $query) => $query
|
||||
->where('product_type', 'server_rental')
|
||||
->orWhere('name', 'like', '%dedicated%')),
|
||||
default => $products->whereRaw('1 = 0'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\ServiceSslCertificate;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use InvalidArgumentException;
|
||||
use ZipArchive;
|
||||
|
||||
class SslBundleExtractor
|
||||
{
|
||||
private const MAX_TOTAL_BYTES = 2097152;
|
||||
|
||||
public static function extract(UploadedFile $archive): array
|
||||
{
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($archive->getRealPath(), ZipArchive::RDONLY) !== true) {
|
||||
throw new InvalidArgumentException('Không đọc được file zip.');
|
||||
}
|
||||
|
||||
$files = [];
|
||||
$total = 0;
|
||||
|
||||
for ($index = 0; $index < $zip->numFiles; $index++) {
|
||||
$name = self::cleanName($zip->getNameIndex($index));
|
||||
if ($name === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = $zip->getFromIndex($index, ServiceSslCertificate::MAX_FILE_BYTES + 1);
|
||||
if ($content === false || strlen($content) > ServiceSslCertificate::MAX_FILE_BYTES || ! self::isText($content)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$total += strlen($content);
|
||||
if ($total > self::MAX_TOTAL_BYTES || count($files) >= ServiceSslCertificate::MAX_ENTRIES) {
|
||||
throw new InvalidArgumentException('File zip chứa quá nhiều dữ liệu.');
|
||||
}
|
||||
|
||||
$files[$name] = ['name' => $name, 'content' => $content];
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
if ($files === []) {
|
||||
throw new InvalidArgumentException('File zip không có tệp văn bản nào (pem, crt, key, txt...).');
|
||||
}
|
||||
|
||||
return array_values($files);
|
||||
}
|
||||
|
||||
private static function cleanName(string $entry): ?string
|
||||
{
|
||||
if (str_ends_with($entry, '/') || str_contains($entry, '__MACOSX/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$name = trim(basename($entry));
|
||||
|
||||
return $name === '' || str_starts_with($name, '.') ? null : mb_substr($name, 0, 150);
|
||||
}
|
||||
|
||||
private static function isText(string $content): bool
|
||||
{
|
||||
return ! str_contains($content, "\0") && mb_check_encoding($content, 'UTF-8');
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ return [
|
||||
// openssl rand -hex 32
|
||||
'key' => env('CLIENT_API_ACCESS_KEY'),
|
||||
'secret' => env('CLIENT_API_ACCESS_SECRET'),
|
||||
'response_key' => env('CLIENT_RESPONSE_KEY'),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
use Database\Seeders\TemplateSeeder;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
(new TemplateSeeder())->seedServiceLifecycleTemplates();
|
||||
}
|
||||
|
||||
public function down(): void {}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('service_attachments', function (Blueprint $table) {
|
||||
$table->string('audience', 20)->default('admin')->after('service_id')->index();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('service_attachments', function (Blueprint $table) {
|
||||
$table->dropColumn('audience');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('service_ssl_certificates', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('service_id')->unique()->constrained('services')->cascadeOnDelete();
|
||||
$table->longText('data');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('service_ssl_certificates');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Database\Seeders\TemplateSeeder;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('service_cancellation_requests', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('service_id')->constrained('services')->cascadeOnDelete();
|
||||
$table->foreignId('customer_id')->constrained('customers')->cascadeOnDelete();
|
||||
$table->string('reason_code', 40)->nullable();
|
||||
$table->text('reason');
|
||||
$table->string('status', 20)->default('pending')->index();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
(new TemplateSeeder())->seedScenarioIfMissing('admin_service_cancellation_request');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('service_cancellation_requests');
|
||||
DB::table('template')->where('scenario', 'admin_service_cancellation_request')->delete();
|
||||
}
|
||||
};
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('service_cancellation_requests', function (Blueprint $table) {
|
||||
$table->text('admin_note')->nullable()->after('reason');
|
||||
$table->timestamp('resolved_at')->nullable()->after('status');
|
||||
$table->string('resolved_by', 100)->nullable()->after('resolved_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('service_cancellation_requests', function (Blueprint $table) {
|
||||
$table->dropColumn(['admin_note', 'resolved_at', 'resolved_by']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('services', function (Blueprint $table) {
|
||||
$table->boolean('cancel_at_expiry')->default(false)->after('auto_renew')->index();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('services', function (Blueprint $table) {
|
||||
$table->dropColumn('cancel_at_expiry');
|
||||
});
|
||||
}
|
||||
};
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
use Database\Seeders\TemplateSeeder;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
(new TemplateSeeder())->seedScenarioIfMissing('admin_customer_service_renewed');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::table('template')->where('scenario', 'admin_customer_service_renewed')->delete();
|
||||
}
|
||||
};
|
||||
@@ -166,6 +166,44 @@ final class AdminTemplates
|
||||
. T::note('{{description}}', 'Nội dung:')
|
||||
. T::button('{{ticket_url}}', 'XEM TICKET'),
|
||||
],
|
||||
[
|
||||
'scenario' => 'admin_customer_service_renewed',
|
||||
'name' => '[Admin] Khách hàng gia hạn dịch vụ',
|
||||
'subject' => '[ADMIN] {{customer_name}} gia hạn dịch vụ {{product_name}}',
|
||||
'body' => T::title('Khách hàng vừa gia hạn dịch vụ')
|
||||
. T::paragraph('Một dịch vụ trên hệ thống <strong>{{company_name}}</strong> vừa được gia hạn thành công.')
|
||||
. T::infoTable([
|
||||
'Hình thức' => '{{renewal_source_label}}',
|
||||
'Khách hàng' => '{{customer_name}}',
|
||||
'Email' => '{{customer_email}}',
|
||||
'Dịch vụ' => '{{product_name}}{{server_name_suffix}}',
|
||||
'Thời gian gia hạn' => '{{months_label}}',
|
||||
'Hạn sử dụng trước' => '{{old_expires_at}}',
|
||||
'Hạn sử dụng mới' => '{{expires_at}}',
|
||||
'Hoá đơn' => '{{invoice_number}}',
|
||||
'Số tiền' => '{{amount_formatted}}',
|
||||
'Thanh toán' => '{{payment_method_label}}',
|
||||
])
|
||||
. T::button('{{admin_url}}', 'XEM DỊCH VỤ'),
|
||||
],
|
||||
[
|
||||
'scenario' => 'admin_service_cancellation_request',
|
||||
'name' => '[Admin] Khách yêu cầu hủy dịch vụ',
|
||||
'subject' => '[ADMIN] {{customer_name}} yêu cầu hủy dịch vụ {{product_name}}',
|
||||
'body' => T::title('Khách hàng yêu cầu hủy dịch vụ')
|
||||
. T::paragraph('Khách hàng vừa gửi yêu cầu hủy dịch vụ trên hệ thống <strong>{{company_name}}</strong>. Dịch vụ chưa bị hủy, cần nhân viên liên hệ và xử lý.')
|
||||
. T::infoTable([
|
||||
'Khách hàng' => '{{customer_name}}',
|
||||
'Email' => '{{customer_email}}',
|
||||
'Dịch vụ' => '{{product_name}}',
|
||||
'Tên dịch vụ' => '{{server_name}}',
|
||||
'Ngày hết hạn' => '{{expires_at}}',
|
||||
'Lý do hủy' => '{{cancel_reason}}',
|
||||
'Thời gian' => '{{created_at}}',
|
||||
'Địa chỉ IP' => '{{client_ip}}',
|
||||
])
|
||||
. T::button('{{admin_url}}', 'XEM DỊCH VỤ'),
|
||||
],
|
||||
[
|
||||
'scenario' => 'admin_login_alert',
|
||||
'name' => '[Admin] Có người đăng nhập trang quản trị',
|
||||
|
||||
@@ -132,6 +132,10 @@ final class ServiceLifecycleTemplates
|
||||
$items[] = self::genericNewService($hasAccount);
|
||||
}
|
||||
|
||||
foreach ([true, false] as $withPassword) {
|
||||
$items[] = self::resendAccount($withPassword);
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
@@ -193,6 +197,27 @@ final class ServiceLifecycleTemplates
|
||||
);
|
||||
}
|
||||
|
||||
private static function resendAccount(bool $withPassword): array
|
||||
{
|
||||
$loginLink = '<a style="color: #c2410c;" href="{{login_url}}" target="_blank" rel="noopener">{{login_url}}</a>';
|
||||
$rows = ['Trang đăng nhập' => $loginLink, 'Email đăng nhập' => '{{account_email}}'];
|
||||
$rows['Mật khẩu'] = $withPassword ? '{{account_pass}}' : 'Mật khẩu hiện tại của Quý khách';
|
||||
|
||||
return self::template(
|
||||
'Tài khoản — Gửi lại thông tin tài khoản (' . ($withPassword ? 'kèm mật khẩu' : 'không kèm mật khẩu') . ')',
|
||||
'V/v: Thế Giới Số gửi lại thông tin tài khoản | {{client_name}}',
|
||||
T::greeting('{{client_name}}')
|
||||
. T::paragraph('Theo yêu cầu của Quý khách, <strong>{{app_name}}</strong> xin gửi lại thông tin tài khoản đăng nhập như sau:')
|
||||
. T::infoTable($rows)
|
||||
. ($withPassword
|
||||
? T::note('Quý khách vui lòng đổi mật khẩu ngay sau khi đăng nhập và không chia sẻ thông tin tài khoản cho người khác.', 'Bảo mật:')
|
||||
: T::note('Nếu không nhớ mật khẩu, Quý khách chọn <strong>"Quên mật khẩu"</strong> tại trang đăng nhập để đặt lại.'))
|
||||
. T::button('{{login_url}}', 'ĐĂNG NHẬP')
|
||||
. T::supportLine()
|
||||
. T::paragraph('Trân trọng,'),
|
||||
);
|
||||
}
|
||||
|
||||
private static function genericNewService(bool $hasAccount): array
|
||||
{
|
||||
$accessRows = '';
|
||||
|
||||
@@ -39,6 +39,8 @@ final class SubjectFormat
|
||||
'admin_trial_registration_request' => ['[ADMIN] yêu cầu dùng thử Cloud VPS', null, 'customer_name'],
|
||||
'admin_manual_deposit_pending' => ['[ADMIN] yêu cầu nạp tiền chờ duyệt {{transaction_code}}', null, 'customer_name'],
|
||||
'admin_ticket_notification' => ['[ADMIN] ticket mới #{{ticket_num}}', null, 'customer_name'],
|
||||
'admin_customer_service_renewed' => ['[ADMIN] {{customer_name}} gia hạn dịch vụ {{product_name}}', null, null],
|
||||
'admin_service_cancellation_request' => ['[ADMIN] {{customer_name}} yêu cầu hủy dịch vụ {{product_name}}', null, null],
|
||||
'admin_login_alert' => ['[ADMIN] {{admin_username}} đăng nhập trang quản trị từ IP {{client_ip}}', null, null],
|
||||
'admin_payment_received' => ['[ADMIN] khách hàng thanh toán {{payment_type}} #{{reference}}', null, 'customer_name'],
|
||||
'admin_service_renewed' => ['[ADMIN] {{admin_name}} gia hạn dịch vụ', 'product_name', 'customer_name'],
|
||||
|
||||
@@ -112,6 +112,8 @@ final class TemplateVariables
|
||||
'status_line' => ['Tình trạng hạn thanh toán', 'string', 'Hoá đơn sẽ đến hạn sau 3 ngày.'],
|
||||
'paid_row' => ['[Tự động] Dòng đã thanh toán', 'html', ''],
|
||||
'transaction_code' => ['Mã giao dịch', 'string', 'NAP20261008001'],
|
||||
'renewal_source_label' => ['Hình thức gia hạn', 'string', 'Khách hàng tự gia hạn'],
|
||||
'cancel_reason' => ['Lý do hủy dịch vụ', 'string', 'Không còn nhu cầu sử dụng'],
|
||||
'admin_username' => ['Tài khoản quản trị', 'string', 'admin'],
|
||||
'login_time' => ['Thời gian đăng nhập', 'string', '23/10/2026 08:30:00'],
|
||||
'client_ip' => ['Địa chỉ IP', 'string', '203.113.10.20'],
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
use App\Http\Controllers\Api\Admin\Customer\CustomerBillingProfileController;
|
||||
use App\Http\Controllers\Api\Admin\Customer\CustomerController;
|
||||
use App\Http\Controllers\Api\Admin\Service\ServiceCancellationRequestController;
|
||||
use App\Http\Controllers\Api\Admin\Service\ServiceController;
|
||||
use App\Http\Controllers\Api\Admin\Service\ServiceRenewalReminderController;
|
||||
use App\Http\Controllers\Api\Admin\Service\SslCertificateController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
// Customers (module: customer)
|
||||
@@ -98,6 +100,17 @@ Route::prefix('services')->controller(ServiceController::class)->group(function
|
||||
Route::post('{id}/attachments/{attachmentId}/copy-to-invoice', 'copyAttachmentToInvoice')->middleware('perm:invoice.edit');
|
||||
});
|
||||
|
||||
Route::prefix('services/{id}/cancellation-requests')->controller(ServiceCancellationRequestController::class)->group(function () {
|
||||
Route::get('/', 'index')->middleware('perm:customer.view');
|
||||
Route::post('{requestId}/resolve', 'resolve')->middleware('perm:customer.edit');
|
||||
});
|
||||
|
||||
Route::prefix('services/{id}/ssl-info')->controller(SslCertificateController::class)->group(function () {
|
||||
Route::get('/', 'show')->middleware('perm:customer.view');
|
||||
Route::put('/', 'update')->middleware('perm:customer.edit');
|
||||
Route::post('archive', 'uploadArchive')->middleware('perm:customer.edit');
|
||||
});
|
||||
|
||||
Route::prefix('customers/{uuid}/billing-profile')->controller(CustomerBillingProfileController::class)->group(function () {
|
||||
Route::get('/', 'show')->middleware('perm:customer.view');
|
||||
Route::put('/', 'update')->middleware('perm:customer.edit');
|
||||
|
||||
+16
-5
@@ -1,11 +1,16 @@
|
||||
<?php
|
||||
|
||||
use App\Support\ServiceGroup;
|
||||
use App\Http\Controllers\Api\Setting\SettingController;
|
||||
use App\Http\Controllers\Api\Client\ClientController;
|
||||
use App\Http\Controllers\Api\Client\Domain\DomainOrderController as ClientDomainOrderController;
|
||||
use App\Http\Controllers\Api\Client\Domain\DomainPricingController;
|
||||
use App\Http\Controllers\Api\Client\Service\DomainController as ClientDomainController;
|
||||
use App\Http\Controllers\Api\Client\Service\ServiceController as ClientServiceController;
|
||||
use App\Http\Controllers\Api\Client\Service\SslArchiveController;
|
||||
use App\Http\Controllers\Api\Client\Service\ServiceRenewalHistoryController;
|
||||
use App\Http\Controllers\Api\Client\Service\HostingPasswordController;
|
||||
use App\Http\Controllers\Api\Client\Service\ServiceCancellationController;
|
||||
use App\Http\Controllers\Api\Service\CouponPreviewController;
|
||||
use App\Http\Controllers\Api\Service\OrderControllerRefactor;
|
||||
use App\Http\Controllers\Api\Service\ProductCategoryController;
|
||||
@@ -122,15 +127,21 @@ Route::prefix('v1')
|
||||
Route::get('hosting', 'indexHosting')->name('hosting.list');
|
||||
Route::get('web-design', 'indexWebDesign')->name('web-design.list');
|
||||
Route::get('domain', 'indexDomain')->name('domain.list');
|
||||
Route::get('{uuid}', 'getDetail')->middleware('seal.response:always')->name('detail');
|
||||
Route::get('{uuid}/vm-password', 'getVmPassword')->middleware(['throttle:20,1', 'seal.response:always'])->name('vm-password');
|
||||
Route::get('{uuid}/console-ticket', 'getConsoleTicket')->middleware('seal.response:always')->name('console-ticket');
|
||||
Route::get('group/{group}', 'indexGroup')->whereIn('group', ServiceGroup::ALL)->name('group.list');
|
||||
Route::get('{uuid}', 'getDetail')->name('detail');
|
||||
Route::get('{uuid}/attachments/{attachmentId}', 'downloadAttachment')->whereNumber('attachmentId')->name('attachments.download');
|
||||
Route::post('{uuid}/cancel-request', [ServiceCancellationController::class, 'store'])->middleware('throttle:5,1')->name('cancel-request.store');
|
||||
Route::delete('{uuid}/cancel-request', [ServiceCancellationController::class, 'destroy'])->name('cancel-request.destroy');
|
||||
Route::get('{uuid}/renewals', ServiceRenewalHistoryController::class)->name('renewals');
|
||||
Route::get('{uuid}/ssl-archive', SslArchiveController::class)->name('ssl-archive');
|
||||
Route::put('{uuid}/hosting-password', HostingPasswordController::class)->middleware('throttle:5,1')->name('hosting-password.update');
|
||||
Route::get('{uuid}/console-ticket', 'getConsoleTicket')->name('console-ticket');
|
||||
Route::get('{uuid}/available-os', 'getAvailableOs')->name('available-os');
|
||||
Route::post('{uuid}/rebuild', 'rebuild')->name('rebuild');
|
||||
Route::patch('{uuid}/auto-renew', 'updateAutoRenew')->name('auto-renew.update');
|
||||
});
|
||||
|
||||
Route::middleware(['api.auth', 'seal.response:always'])
|
||||
Route::middleware(['api.auth'])
|
||||
->get('get-ticket-console', [ClientServiceController::class, 'getTicketByOrderNum'])
|
||||
->name('console-ticket');
|
||||
|
||||
@@ -325,7 +336,7 @@ Route::prefix('v1/system')
|
||||
Route::post('vm-change-ip', 'changeIp')->name('vm.change-ip');
|
||||
Route::post('vm-change-port', 'changePort')->name('vm.change-port');
|
||||
Route::post('vm-change-password', 'changePassword')->name('vm.change-password');
|
||||
Route::get('get-ticket-console', 'consoleTicket')->middleware('seal.response:always')->name('vm.console-ticket');
|
||||
Route::get('get-ticket-console', 'consoleTicket')->name('vm.console-ticket');
|
||||
Route::post('vm-restart', 'restart')->name('vm.restart');
|
||||
});
|
||||
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
@@ -32,6 +32,13 @@ class DnsServerDomainTest extends TestCase
|
||||
$this->assertSame('103.42.58.99', $server->publicHost());
|
||||
}
|
||||
|
||||
public function test_panel_url_is_https_on_public_host_without_port_or_script(): void
|
||||
{
|
||||
$server = new DnsServer(['ip_address' => '103.42.58.99', 'domain' => 'login.tgs.com.vn', 'panel_port' => 8880]);
|
||||
|
||||
$this->assertSame('https://login.tgs.com.vn/', $server->panelUrl());
|
||||
}
|
||||
|
||||
public function test_store_saves_domain_and_rejects_invalid_hostname(): void
|
||||
{
|
||||
$admin = $this->admin();
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api\Admin\Product;
|
||||
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductCategories;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProductCategoryFilterTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_category_filter_applies_before_pagination(): void
|
||||
{
|
||||
Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('Full Administrator');
|
||||
|
||||
$wordpress = ProductCategories::create([
|
||||
'name' => 'HOSTING WORDPRESS',
|
||||
'slug' => 'wordpress-hosting',
|
||||
'type' => 'hosting',
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
$other = ProductCategories::create([
|
||||
'name' => 'Other',
|
||||
'slug' => 'other',
|
||||
'type' => 'hosting',
|
||||
'is_active' => true,
|
||||
'sort_order' => 2,
|
||||
]);
|
||||
|
||||
$expected = Product::factory()->create(['product_categories_id' => $wordpress->id]);
|
||||
Product::factory()->count(16)->create(['product_categories_id' => $other->id]);
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->getJson("/api/v1/administrator/products?per_page=15&product_categories_id={$wordpress->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.total', 1)
|
||||
->assertJsonCount(1, 'data.items')
|
||||
->assertJsonPath('data.items.0.id', $expected->id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api\Admin\Service;
|
||||
|
||||
use App\Http\Resources\Api\Client\Service\ServiceClientDetailResource;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Product;
|
||||
use App\Models\Service;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Http\Request;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Tests\TestCase;
|
||||
use ZipArchive;
|
||||
|
||||
class SslCertificateTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $admin;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
|
||||
$this->admin = User::factory()->create();
|
||||
$this->admin->assignRole('Full Administrator');
|
||||
}
|
||||
|
||||
private function service(string $productType = 'ssl'): Service
|
||||
{
|
||||
$product = Product::factory()->create(['product_type' => $productType, 'name' => 'SSL - Positive']);
|
||||
|
||||
return Service::create(['customer_id' => Customer::factory()->create()->id, 'product_id' => $product->id, 'status' => 'active']);
|
||||
}
|
||||
|
||||
private function zip(array $entries): UploadedFile
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'ssl') . '.zip';
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($path, ZipArchive::CREATE);
|
||||
foreach ($entries as $name => $content) {
|
||||
$zip->addFromString($name, $content);
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
return new UploadedFile($path, 'cert.zip', 'application/zip', null, true);
|
||||
}
|
||||
|
||||
public function test_archive_is_extracted_to_text_files_and_visible_to_client(): void
|
||||
{
|
||||
$service = $this->service();
|
||||
$base = "/api/v1/administrator/services/{$service->id}/ssl-info";
|
||||
|
||||
$this->actingAs($this->admin, 'admin')
|
||||
->post("{$base}/archive", ['archive' => $this->zip([
|
||||
'bundle/example.com.crt' => "-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----",
|
||||
'../../evil.key' => 'key-data',
|
||||
'__MACOSX/._example.com.crt' => 'junk',
|
||||
'logo.png' => "\0\1binary",
|
||||
'dir/' => '',
|
||||
])], ['Accept' => 'application/json'])
|
||||
->assertOk()
|
||||
->assertJsonCount(2, 'data.files')
|
||||
->assertJsonPath('data.files.0.name', 'example.com.crt')
|
||||
->assertJsonPath('data.files.1.name', 'evil.key');
|
||||
|
||||
$this->actingAs($this->admin, 'admin')
|
||||
->putJson($base, [
|
||||
'fields' => [['label' => 'CNAME host', 'value' => '_abc.example.com']],
|
||||
'files' => [['name' => 'a.pem', 'content' => 'pem']],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$this->actingAs($this->admin, 'admin')->getJson($base)
|
||||
->assertJsonPath('data.fields.0.value', '_abc.example.com')
|
||||
->assertJsonCount(1, 'data.files');
|
||||
|
||||
$this->assertStringNotContainsString('pem', (string) \DB::table('service_ssl_certificates')->value('data'));
|
||||
|
||||
$payload = (new ServiceClientDetailResource($service->fresh()))->toArray(new Request());
|
||||
$this->assertSame('a.pem', $payload['ssl_certificate']['files'][0]['name']);
|
||||
}
|
||||
|
||||
public function test_non_ssl_service_is_rejected(): void
|
||||
{
|
||||
$service = $this->service('domain');
|
||||
|
||||
$this->actingAs($this->admin, 'admin')
|
||||
->getJson("/api/v1/administrator/services/{$service->id}/ssl-info")
|
||||
->assertStatus(422);
|
||||
}
|
||||
|
||||
public function test_customer_downloads_own_certificate_zip_only(): void
|
||||
{
|
||||
$service = $this->service();
|
||||
$service->sslCertificate()->create(['data' => [
|
||||
'fields' => [['label' => 'CNAME', 'value' => 'abc']],
|
||||
'files' => [['name' => 'a.pem', 'content' => 'pem-data']],
|
||||
]]);
|
||||
$owner = Customer::find($service->customer_id);
|
||||
$path = "/v1/client/client-service/{$service->uuid}/ssl-archive";
|
||||
|
||||
$response = $this->actingAs($owner, 'client')->withHeaders(['x-access-token' => $this->buildClientAccessToken()])->get($path);
|
||||
$response->assertOk();
|
||||
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($response->baseResponse->getFile()->getPathname());
|
||||
$this->assertSame('pem-data', $zip->getFromName('a.pem'));
|
||||
$this->assertStringContainsString('CNAME: abc', $zip->getFromName('thong-tin.txt'));
|
||||
|
||||
$this->actingAs(Customer::factory()->create(), 'client')->withHeaders(['x-access-token' => $this->buildClientAccessToken()])->get($path)->assertNotFound();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api\Client;
|
||||
|
||||
use App\Mail\Admin\ServiceCancellationRequestMail;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Notification;
|
||||
use App\Models\Service;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ServiceCancellationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private Customer $owner;
|
||||
private Service $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Mail::fake();
|
||||
Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
|
||||
User::factory()->create()->assignRole('Full Administrator');
|
||||
Setting::updateOrCreate(['key' => 'setting'], ['value' => json_encode(['admin_email' => 'admin@example.com'])]);
|
||||
|
||||
$this->owner = Customer::factory()->create();
|
||||
$this->service = Service::create(['customer_id' => $this->owner->id, 'type' => 'ssl', 'status' => 'active', 'server_name' => 'example.com']);
|
||||
}
|
||||
|
||||
private function as(Customer $customer): static
|
||||
{
|
||||
return $this->actingAs($customer, 'client')->withHeaders(['x-access-token' => $this->buildClientAccessToken()]);
|
||||
}
|
||||
|
||||
public function test_request_notifies_admins_and_blocks_duplicates_until_withdrawn(): void
|
||||
{
|
||||
$url = "/v1/client/client-service/{$this->service->uuid}/cancel-request";
|
||||
|
||||
$this->as($this->owner)->postJson($url, ['reason_code' => 'other', 'reason' => 'Chuyển sang nhà cung cấp khác'])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.reason', 'Chuyển sang nhà cung cấp khác');
|
||||
|
||||
$this->assertSame(1, Notification::where('type', 'service_cancellation_request')->where('recipient_type', 'admin')->count());
|
||||
Mail::assertQueued(ServiceCancellationRequestMail::class, fn ($mail) => $mail->hasTo('admin@example.com'));
|
||||
|
||||
$this->as($this->owner)->postJson($url, ['reason' => 'Lặp lại'])->assertStatus(422);
|
||||
|
||||
$this->as($this->owner)->deleteJson($url)->assertOk();
|
||||
$this->as($this->owner)->postJson($url, ['reason' => 'Gửi lại sau khi rút'])->assertCreated();
|
||||
}
|
||||
|
||||
public function test_other_customer_cannot_request_and_reason_is_required(): void
|
||||
{
|
||||
$url = "/v1/client/client-service/{$this->service->uuid}/cancel-request";
|
||||
|
||||
$this->as(Customer::factory()->create())->postJson($url, ['reason' => 'Hủy giúp'])->assertNotFound();
|
||||
$this->as($this->owner)->postJson($url, [])->assertStatus(422);
|
||||
$this->assertSame(0, Notification::where('type', 'service_cancellation_request')->count());
|
||||
}
|
||||
|
||||
public function test_admin_approves_or_rejects_once_and_customer_is_notified(): void
|
||||
{
|
||||
$admin = User::first();
|
||||
$pending = $this->service->cancellationRequests()->create(['customer_id' => $this->owner->id, 'reason' => 'Không dùng nữa']);
|
||||
$base = "/api/v1/administrator/services/{$this->service->id}/cancellation-requests";
|
||||
|
||||
$this->actingAs($admin, 'admin')->postJson("{$base}/{$pending->id}/resolve", ['action' => 'reject'])->assertStatus(422);
|
||||
$this->actingAs($admin, 'admin')->postJson("{$base}/{$pending->id}/resolve", ['action' => 'reject', 'note' => 'Còn hợp đồng'])->assertOk();
|
||||
$this->assertSame('active', $this->service->fresh()->status);
|
||||
$this->actingAs($admin, 'admin')->postJson("{$base}/{$pending->id}/resolve", ['action' => 'approve'])->assertStatus(422);
|
||||
|
||||
$second = $this->service->cancellationRequests()->create(['customer_id' => $this->owner->id, 'reason' => 'Lần hai']);
|
||||
$this->actingAs($admin, 'admin')->postJson("{$base}/{$second->id}/resolve", ['action' => 'approve'])->assertOk();
|
||||
|
||||
$this->assertSame('cancelled', $this->service->fresh()->status);
|
||||
$this->assertSame(2, Notification::where('recipient_type', 'client')->where('type', 'service_cancellation_resolved')->count());
|
||||
$this->actingAs($admin, 'admin')->getJson($base)->assertJsonCount(2, 'data');
|
||||
}
|
||||
|
||||
public function test_approved_cancellation_keeps_service_until_expiry_then_cancels(): void
|
||||
{
|
||||
$this->service->update(['expires_at' => now()->addDays(5), 'auto_renew' => true]);
|
||||
$pending = $this->service->cancellationRequests()->create(['customer_id' => $this->owner->id, 'reason' => 'Không dùng nữa']);
|
||||
|
||||
$this->actingAs(User::first(), 'admin')
|
||||
->postJson("/api/v1/administrator/services/{$this->service->id}/cancellation-requests/{$pending->id}/resolve", ['action' => 'approve'])
|
||||
->assertOk();
|
||||
|
||||
$service = $this->service->fresh();
|
||||
$this->assertSame('active', $service->status);
|
||||
$this->assertTrue($service->cancel_at_expiry);
|
||||
$this->assertFalse($service->auto_renew);
|
||||
$this->assertNull($service->cancelled_at);
|
||||
|
||||
$this->as($this->owner)->postJson("/v1/client/client-service/{$service->uuid}/cancel-request", ['reason' => 'Gửi lại'])->assertStatus(422);
|
||||
|
||||
$this->artisan('service:expiry')->assertSuccessful();
|
||||
$this->assertSame('active', $service->fresh()->status);
|
||||
|
||||
$service->update(['expires_at' => now()->subHour()]);
|
||||
$this->artisan('service:expiry')->assertSuccessful();
|
||||
$this->assertSame('cancelled', $service->fresh()->status);
|
||||
$this->assertNotNull($service->fresh()->cancelled_at);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,13 @@ use App\Models\OrderItem;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductPricings;
|
||||
use App\Models\Service;
|
||||
use App\Mail\ServiceRenewedMail;
|
||||
use App\Models\Notification;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Tests\TestCase;
|
||||
|
||||
class RenewServiceTest extends TestCase
|
||||
@@ -108,4 +114,51 @@ class RenewServiceTest extends TestCase
|
||||
$this->renew(2)->assertStatus(422);
|
||||
$this->assertSame(0, Invoice::where('invoice_type', 2)->count());
|
||||
}
|
||||
|
||||
public function test_customer_renewal_notifies_admins_by_notification_and_email(): void
|
||||
{
|
||||
Mail::fake();
|
||||
Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
|
||||
User::factory()->create()->assignRole('Full Administrator');
|
||||
Setting::updateOrCreate(['key' => 'setting'], ['value' => json_encode(['admin_email' => 'admin@example.com'])]);
|
||||
|
||||
$this->renew(3)->assertOk();
|
||||
|
||||
$notification = Notification::where('type', 'service_renewed_by_customer')->where('recipient_type', 'admin')->sole();
|
||||
$this->assertSame('/services/' . $this->service->id, $notification->link);
|
||||
Mail::assertQueued(ServiceRenewedMail::class, fn ($mail) => $mail->hasTo('admin@example.com'));
|
||||
}
|
||||
|
||||
public function test_customer_reads_own_renewal_history_only(): void
|
||||
{
|
||||
$this->renew(3)->assertOk();
|
||||
$path = "/v1/client/client-service/{$this->service->uuid}/renewals";
|
||||
$headers = ['x-access-token' => $this->buildClientAccessToken()];
|
||||
|
||||
$this->actingAs($this->customer, 'client')->withHeaders($headers)->getJson($path)
|
||||
->assertOk()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.months', 3)
|
||||
->assertJsonPath('data.0.by', 'Bạn')
|
||||
->assertJsonPath('data.0.amount', 40000);
|
||||
|
||||
$this->actingAs(Customer::factory()->create(), 'client')->withHeaders($headers)->getJson($path)->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_renewal_invoice_detail_reports_renewal_term_and_next_date(): void
|
||||
{
|
||||
$expiresAt = $this->service->expires_at->copy();
|
||||
$this->renew(3)->assertOk();
|
||||
$invoice = Invoice::where('invoice_type', 2)->sole();
|
||||
|
||||
$this->actingAs($this->customer, 'client')
|
||||
->withHeaders(['x-access-token' => $this->buildClientAccessToken()])
|
||||
->getJson("/v1/client/invoices/order/ORD-RENEW-TEST?invoice={$invoice->invoice_number}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.service.months', 3)
|
||||
->assertJsonPath('data.items.0.months', 3)
|
||||
->assertJsonPath('data.items.0.price', 40000)
|
||||
->assertJsonPath('data.billing_period_end', $expiresAt->copy()->addMonths(3)->toDateString())
|
||||
->assertJsonPath('data.next_renewal_date', $expiresAt->copy()->addMonths(3)->toDateString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Product;
|
||||
use App\Support\ServiceGroup;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ServiceGroupTest extends TestCase
|
||||
{
|
||||
public function test_of_classifies_by_type_then_name(): void
|
||||
{
|
||||
$this->assertSame('email', ServiceGroup::of('email', 'Email Business'));
|
||||
$this->assertSame('ssl', ServiceGroup::of('ssl', 'Whatever'));
|
||||
$this->assertSame('ssl', ServiceGroup::of('other', 'SSL - Positive'));
|
||||
$this->assertSame('colo', ServiceGroup::of('other', 'COLO VNPT STAR'));
|
||||
$this->assertSame('colo', ServiceGroup::of('server_rental', 'Chỗ đặt 1U'));
|
||||
$this->assertSame('dedicated', ServiceGroup::of('other', 'DEDICATED GOLD'));
|
||||
$this->assertSame('dedicated', ServiceGroup::of('server_rental', 'Máy chủ X'));
|
||||
$this->assertNull(ServiceGroup::of('other', 'Khác'));
|
||||
$this->assertNull(ServiceGroup::of('vps', 'colo vps'));
|
||||
}
|
||||
|
||||
public function test_constrain_sql_matches_of(): void
|
||||
{
|
||||
foreach (ServiceGroup::ALL as $group) {
|
||||
$sql = ServiceGroup::constrain(Product::query(), $group)->toSql();
|
||||
$this->assertStringContainsString('product_type', $sql, $group);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ class TemplateSeederTest extends TestCase
|
||||
public function test_service_lifecycle_templates_are_seeded_once_as_marketing(): void
|
||||
{
|
||||
$items = ServiceLifecycleTemplates::all();
|
||||
$this->assertCount(37, $items);
|
||||
$this->assertCount(39, $items);
|
||||
|
||||
$this->seed(TemplateSeeder::class);
|
||||
$this->seed(TemplateSeeder::class);
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Http\Middleware\SealResponse;
|
||||
use App\Support\ResponseCipher;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ResponseCipherTest extends TestCase
|
||||
{
|
||||
private function key(): string
|
||||
{
|
||||
return str_repeat('k', 32);
|
||||
}
|
||||
|
||||
private function request(array $body = [], bool $optIn = false, ?string $key = null): Request
|
||||
{
|
||||
$request = Request::create('/x', 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], json_encode($body));
|
||||
$request->headers->set('x-seal-key', base64_encode($key ?? $this->key()));
|
||||
if ($optIn) {
|
||||
$request->headers->set('x-seal', '1');
|
||||
}
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
public function test_always_mode_seals_with_session_key_and_is_reversible(): void
|
||||
{
|
||||
$response = (new SealResponse())->handle(
|
||||
$this->request(),
|
||||
fn () => new JsonResponse(['data' => ['password' => 'Cloud226*()']]),
|
||||
'always',
|
||||
);
|
||||
|
||||
$body = $response->getData(true);
|
||||
$this->assertArrayNotHasKey('data', $body);
|
||||
$this->assertStringNotContainsString('Cloud226', $response->getContent());
|
||||
$this->assertSame(
|
||||
['data' => ['password' => 'Cloud226*()']],
|
||||
json_decode(ResponseCipher::open($body['enc'], $this->key()), true),
|
||||
);
|
||||
$this->assertNull(ResponseCipher::open($body['enc'], str_repeat('x', 32)));
|
||||
}
|
||||
|
||||
public function test_passthrough_without_key_or_opt_in(): void
|
||||
{
|
||||
$noKey = Request::create('/x');
|
||||
$noKey->headers->set('x-seal', '1');
|
||||
$plain = (new SealResponse())->handle($noKey, fn () => new JsonResponse(['a' => 1]));
|
||||
$this->assertSame(['a' => 1], $plain->getData(true));
|
||||
|
||||
$noOptIn = (new SealResponse())->handle($this->request(), fn () => new JsonResponse(['a' => 1]));
|
||||
$this->assertSame(['a' => 1], $noOptIn->getData(true));
|
||||
}
|
||||
|
||||
public function test_opt_in_seals_errors_and_opens_request_body(): void
|
||||
{
|
||||
$sealed = ['enc' => ResponseCipher::seal(json_encode(['name' => 'An']), $this->key())];
|
||||
|
||||
$response = (new SealResponse())->handle(
|
||||
$this->request($sealed, optIn: true),
|
||||
fn (Request $r) => new JsonResponse(['got' => $r->input('name')], 422),
|
||||
);
|
||||
|
||||
$this->assertSame(422, $response->getStatusCode());
|
||||
$this->assertSame(
|
||||
['got' => 'An'],
|
||||
json_decode(ResponseCipher::open($response->getData(true)['enc'], $this->key()), true),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_request_sealed_with_stale_key_gets_428(): void
|
||||
{
|
||||
$sealed = ['enc' => ResponseCipher::seal('{"a":1}', str_repeat('o', 32))];
|
||||
|
||||
$response = (new SealResponse())->handle(
|
||||
$this->request($sealed, optIn: true),
|
||||
fn () => new JsonResponse(['ok' => true]),
|
||||
);
|
||||
|
||||
$this->assertSame(428, $response->getStatusCode());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user